diff --git a/.aegir.js b/.aegir.js index 08fb705e8e..18d6236a58 100644 --- a/.aegir.js +++ b/.aegir.js @@ -45,7 +45,7 @@ const after = async () => { } module.exports = { - bundlesize: { maxSize: '185kB' }, + bundlesize: { maxSize: '200kB' }, hooks: { pre: before, post: after diff --git a/doc/API.md b/doc/API.md index 883c67f5d5..0d1bbff06e 100644 --- a/doc/API.md +++ b/doc/API.md @@ -44,6 +44,17 @@ * [`connectionManager.get`](#connectionmanagerget) * [`connectionManager.setPeerValue`](#connectionmanagersetpeervalue) * [`connectionManager.size`](#connectionmanagersize) + * [`keychain.createKey`](#keychaincreatekey) + * [`keychain.renameKey`](#keychainrenamekey) + * [`keychain.removeKey`](#keychainremovekey) + * [`keychain.exportKey`](#keychainexportkey) + * [`keychain.importKey`](#keychainimportkey) + * [`keychain.importPeer`](#keychainimportpeer) + * [`keychain.list`](#keychainlist) + * [`keychain.findById`](#keychainfindbyid) + * [`keychain.findByName`](#keychainfindbyname) + * [`keychain.cms.encrypt`](#keychaincmsencrypt) + * [`keychain.cms.decrypt`](#keychaincmsdecrypt) * [`metrics.global`](#metricsglobal) * [`metrics.peers`](#metricspeers) * [`metrics.protocols`](#metricsprotocols) @@ -75,7 +86,8 @@ Creates an instance of Libp2p. | [options.connectionManager] | `object` | libp2p Connection Manager configuration | | [options.datastore] | `object` | must implement [ipfs/interface-datastore](https://github.com/ipfs/interface-datastore) (in memory datastore will be used if not provided) | | [options.dialer] | `object` | libp2p Dialer configuration -| [options.metrics] | `object` | libp2p Metrics configuration +| [options.keychain] | [`object`](./CONFIGURATION.md#setup-with-keychain) | libp2p Keychain configuration | +| [options.metrics] | `object` | libp2p Metrics configuration | | [options.peerId] | [`PeerId`][peer-id] | peerId instance (it will be created if not provided) | | [options.peerStore] | `object` | libp2p PeerStore configuration | @@ -470,6 +482,65 @@ const listenMa = libp2p.transportManager.getAddrs() // [ ] ``` +### addressManager.getAnnounceAddrs + +Get the multiaddrs that were provided to announce to the network. + +`libp2p.addressManager.getAnnounceAddrs()` + +#### Returns + +| Type | Description | +|------|-------------| +| `Array` | Provided announce multiaddrs | + +#### Example + +```js +// ... +const announceMa = libp2p.addressManager.getAnnounceAddrs() +// [ ] +``` + +### addressManager.getNoAnnounceAddrs + +Get the multiaddrs that were provided to not announce to the network. + +`libp2p.addressManager.getNoAnnounceAddrs()` + +#### Returns + +| Type | Description | +|------|-------------| +| `Array` | Provided noAnnounce multiaddrs | + +#### Example + +```js +// ... +const noAnnounceMa = libp2p.addressManager.getNoAnnounceAddrs() +// [ ] +``` + +### transportManager.getAddrs + +Get the multiaddrs that libp2p transports are using to listen on. + +`libp2p.transportManager.getAddrs()` + +#### Returns + +| Type | Description | +|------|-------------| +| `Array` | listening multiaddrs | + +#### Example + +```js +// ... +const listenMa = libp2p.transportManager.getAddrs() +// [ ] + ### contentRouting.findProviders Iterates over all content routers in series to find providers of the given key. @@ -1254,6 +1325,278 @@ libp2p.connectionManager.size // 10 ``` +### keychain.createKey + +Create a key in the keychain. + +`libp2p.keychain.createKey(name, type, size)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. It cannot already exist. | +| type | `string` | One of the key types; 'rsa' | +| size | `number` | The key size in bits. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +const keyInfo = await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +``` + +### keychain.renameKey + +Rename a key in the keychain. + +`libp2p.keychain.renameKey(oldName, newName)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The old local key name. It must already exist. | +| type | `string` | The new local key name. It must not already exist. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const keyInfo = await libp2p.keychain.renameKey('keyTest', 'keyNewNtest') +``` + +### keychain.removeKey + +Removes a key from the keychain. + +`libp2p.keychain.removeKey(name)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. It must already exist. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const keyInfo = await libp2p.keychain.removeKey('keyTest') +``` + +### keychain.exportKey + +Export an existing key as a PEM encrypted PKCS #8 string. + +`libp2p.keychain.exportKey(name, password)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. It must already exist. | +| password | `string` | The password to use. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise` | Key as a PEM encrypted PKCS #8 | + +#### Example + +```js +await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const pemKey = await libp2p.keychain.exportKey('keyTest', 'password123') +``` + +### keychain.importKey + +Import a new key from a PEM encoded PKCS #8 string. + +`libp2p.keychain.importKey(name, pem, password)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. It must not exist. | +| pem | `string` | The PEM encoded PKCS #8 string. | +| password | `string` | The password to use. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const pemKey = await libp2p.keychain.exportKey('keyTest', 'password123') +const keyInfo = await libp2p.keychain.importKey('keyTestImport', pemKey, 'password123') +``` + +### keychain.importPeer + +Import a new key from a PeerId. + +`libp2p.keychain.importPeer(name, peerId)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. It must not exist. | +| peerId | ['PeerId'][peer-id] | The PEM encoded PKCS #8 string. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +const keyInfo = await libp2p.keychain.importPeer('keyTestImport', peerId) +``` + +### keychain.list + +List all the keys. + +`libp2p.keychain.list()` + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise>` | Array of Key info | + +#### Example + +```js +const keyInfos = await libp2p.keychain.list() +``` + +### keychain.findById + +Find a key by it's id. + +`libp2p.keychain.findById(id)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| id | `string` | The universally unique key identifier. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +const keyInfo = await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const keyInfo2 = await libp2p.keychain.findById(keyInfo.id) +``` + +### keychain.findByName + +Find a key by it's name. + +`libp2p.keychain.findByName(id)` + +#### Parameters + +| Name | Type | Description | +|------|------|-------------| +| id | `string` | The local key name. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise<{ id, name }>` | Key info object | + +#### Example + +```js +const keyInfo = await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const keyInfo2 = await libp2p.keychain.findByName('keyTest') +``` + +### keychain.cms.encrypt + +Encrypt protected data using the Cryptographic Message Syntax (CMS). + +`libp2p.keychain.cms.encrypt(name, data)` + +| Name | Type | Description | +|------|------|-------------| +| name | `string` | The local key name. | +| data | `Buffer` | The data to encrypt. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise` | Encrypted data as a PKCS #7 message in DER. | + +#### Example + +```js +const keyInfo = await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const enc = await libp2p.keychain.cms.encrypt('keyTest', Buffer.from('data')) +``` + +### keychain.cms.decrypt + +Decrypt protected data using the Cryptographic Message Syntax (CMS). +The keychain must contain one of the keys used to encrypt the data. If none of the keys exists, an Error is returned with the property 'missingKeys'. + +`libp2p.keychain.cms.decrypt(cmsData)` + +| Name | Type | Description | +|------|------|-------------| +| cmsData | `string` | The CMS encrypted data to decrypt. | + +#### Returns + +| Type | Description | +|------|-------------| +| `Promise` | Decrypted data. | + +#### Example + +```js +const keyInfo = await libp2p.keychain.createKey('keyTest', 'rsa', 4096) +const enc = await libp2p.keychain.cms.encrypt('keyTest', Buffer.from('data')) +``` + ### metrics.global A [`Stats`](#stats) object of tracking the global bandwidth of the libp2p node. diff --git a/doc/CONFIGURATION.md b/doc/CONFIGURATION.md index 833b4ac0b1..b9598d069b 100644 --- a/doc/CONFIGURATION.md +++ b/doc/CONFIGURATION.md @@ -20,6 +20,7 @@ - [Customizing DHT](#customizing-dht) - [Setup with Content and Peer Routing](#setup-with-content-and-peer-routing) - [Setup with Relay](#setup-with-relay) + - [Setup with Keychain](#setup-with-keychain) - [Configuring Dialing](#configuring-dialing) - [Configuring Connection Manager](#configuring-connection-manager) - [Configuring Metrics](#configuring-metrics) @@ -422,6 +423,35 @@ const node = await Libp2p.create({ }) ``` +#### Setup with Keychain + +Libp2p allows you to setup a secure key chain to manage your keys. The keychain configuration object should have the following properties: + +| Name | Type | Description | +|------|------|-------------| +| pass | `string` | Passphrase to use in the keychain (minimum of 20 characters). | +| datastore | `object` | must implement [ipfs/interface-datastore](https://github.com/ipfs/interface-datastore) | + +```js +const Libp2p = require('libp2p') +const TCP = require('libp2p-tcp') +const MPLEX = require('libp2p-mplex') +const SECIO = require('libp2p-secio') +const LevelStore = require('datastore-level') + +const node = await Libp2p.create({ + modules: { + transport: [TCP], + streamMuxer: [MPLEX], + connEncryption: [SECIO] + }, + keychain: { + pass: 'notsafepassword123456789', + datastore: new LevelStore('path/to/store') + } +}) +``` + #### Configuring Dialing Dialing in libp2p can be configured to limit the rate of dialing, and how long dials are allowed to take. The below configuration example shows the default values for the dialer. diff --git a/package.json b/package.json index 1335bdc286..be1fe40010 100644 --- a/package.json +++ b/package.json @@ -67,12 +67,14 @@ "multiaddr": "^7.4.3", "multistream-select": "^0.15.0", "mutable-proxy": "^1.0.0", + "node-forge": "^0.9.1", "p-any": "^3.0.0", "p-fifo": "^1.0.0", "p-settle": "^4.0.1", "peer-id": "^0.13.11", "protons": "^1.0.1", "retimer": "^2.0.0", + "sanitize-filename": "^1.6.3", "streaming-iterables": "^4.1.0", "timeout-abort-controller": "^1.0.0", "xsalsa20": "^1.0.2" @@ -84,14 +86,19 @@ "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "chai-bytes": "^0.1.2", + "chai-string": "^1.5.0", "cids": "^0.8.0", + "datastore-fs": "^1.0.0", + "datastore-level": "^1.0.0", "delay": "^4.3.0", "dirty-chai": "^2.0.1", "interop-libp2p": "libp2p/interop#chore/update-libp2p-daemon-with-peerstore", "ipfs-http-client": "^44.0.0", + "ipfs-utils": "^2.2.0", "it-concat": "^1.0.0", "it-pair": "^1.0.0", "it-pushable": "^1.4.0", + "level": "^6.0.1", "libp2p-bootstrap": "^0.11.0", "libp2p-delegated-content-routing": "^0.5.0", "libp2p-delegated-peer-routing": "^0.5.0", @@ -99,16 +106,18 @@ "libp2p-gossipsub": "^0.4.0", "libp2p-kad-dht": "^0.19.1", "libp2p-mdns": "^0.14.1", - "libp2p-noise": "^1.1.0", "libp2p-mplex": "^0.9.5", + "libp2p-noise": "^1.1.0", "libp2p-secio": "^0.12.4", "libp2p-tcp": "^0.14.1", "libp2p-webrtc-star": "^0.18.0", "libp2p-websockets": "^0.13.1", + "multihashes": "^0.4.19", "nock": "^12.0.3", "p-defer": "^3.0.0", "p-times": "^3.0.0", "p-wait-for": "^3.1.0", + "rimraf": "^3.0.2", "sinon": "^9.0.2" }, "contributors": [ diff --git a/src/index.js b/src/index.js index c839d40530..0050e16e0c 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const globalThis = require('ipfs-utils/src/globalthis') const log = debug('libp2p') log.error = debug('libp2p:error') +const { MemoryDatastore } = require('interface-datastore') const PeerId = require('peer-id') const peerRouting = require('./peer-routing') @@ -19,6 +20,8 @@ const AddressManager = require('./address-manager') const ConnectionManager = require('./connection-manager') const Circuit = require('./circuit') const Dialer = require('./dialer') +const Keychain = require('./keychain') +const NoKeychain = require('./keychain/no-keychain') const Metrics = require('./metrics') const TransportManager = require('./transport-manager') const Upgrader = require('./upgrader') @@ -44,8 +47,9 @@ class Libp2p extends EventEmitter { // and add default values where appropriate this._options = validateConfig(_options) - this.peerId = this._options.peerId this.datastore = this._options.datastore + this.keychain = this._options.keychain + this.peerId = this._options.peerId this.peerStore = (this.datastore && this._options.peerStore.persistence) ? new PersistentPeerStore({ @@ -168,6 +172,9 @@ class Libp2p extends EventEmitter { this.peerRouting = peerRouting(this) this.contentRouting = contentRouting(this) + // Keychain + this.keychain = this._options._keychain || new NoKeychain() + // Mount default protocols ping.mount(this) @@ -196,7 +203,6 @@ class Libp2p extends EventEmitter { */ async start () { log('libp2p is starting') - try { await this._onStarting() await this._onDidStart() @@ -542,13 +548,40 @@ class Libp2p extends EventEmitter { * @returns {Libp2p} */ Libp2p.create = async function create (options = {}) { - if (options.peerId) { - return new Libp2p(options) + let peerId = options.peerId + + if (!peerId) { + peerId = await PeerId.create() + + options.peerId = peerId } - const peerId = await PeerId.create() + const keychainOptions = options.keychain || {} + + if (keychainOptions.pass) { + log('creating keychain') + + const datastore = keychainOptions.datastore || new MemoryDatastore() + const keychainOpts = Keychain.generateOptions() + + const keychain = new Keychain(datastore, { + passPhrase: keychainOptions.pass, + ...keychainOpts, + ...keychainOptions + }) + + log('keychain constructed') + + // Import the private key as 'self', if needed. + try { + await keychain.findByName('self') + } catch (err) { + await keychain.importPeer('self', peerId) + } + + options._keychain = keychain + } - options.peerId = peerId return new Libp2p(options) } diff --git a/src/keychain/README.md b/src/keychain/README.md new file mode 100644 index 0000000000..1a5a6ce387 --- /dev/null +++ b/src/keychain/README.md @@ -0,0 +1,55 @@ +# js-libp2p-keychain + +> A secure key chain for libp2p in JavaScript + +## Features + +- Manages the lifecycle of a key +- Keys are encrypted at rest +- Enforces the use of safe key names +- Uses encrypted PKCS 8 for key storage +- Uses PBKDF2 for a "stetched" key encryption key +- Enforces NIST SP 800-131A and NIST SP 800-132 +- Uses PKCS 7: CMS (aka RFC 5652) to provide cryptographically protected messages +- Delays reporting errors to slow down brute force attacks + +### KeyInfo + +The key management and naming service API all return a `KeyInfo` object. The `id` is a universally unique identifier for the key. The `name` is local to the key chain. + +```js +{ + name: 'rsa-key', + id: 'QmYWYSUZ4PV6MRFYpdtEDJBiGs4UrmE6g8wmAWSePekXVW' +} +``` + +The **key id** is the SHA-256 [multihash](https://github.com/multiformats/multihash) of its public key. The *public key* is a [protobuf encoding](https://github.com/libp2p/js-libp2p-crypto/blob/master/src/keys/keys.proto.js) containing a type and the [DER encoding](https://en.wikipedia.org/wiki/X.690) of the PKCS [SubjectPublicKeyInfo](https://www.ietf.org/rfc/rfc3279.txt). + +### Private key storage + +A private key is stored as an encrypted PKCS 8 structure in the PEM format. It is protected by a key generated from the key chain's *passPhrase* using **PBKDF2**. + +The default options for generating the derived encryption key are in the `dek` object. This, along with the passPhrase, is the input to a `PBKDF2` function. + +```js +const defaultOptions = { + //See https://cryptosense.com/parameter-choice-for-pbkdf2/ + dek: { + keyLength: 512 / 8, + iterationCount: 1000, + salt: 'at least 16 characters long', + hash: 'sha2-512' + } +} +``` + +![key storage](./doc/private-key.png?raw=true) + +### Physical storage + +The actual physical storage of an encrypted key is left to implementations of [interface-datastore](https://github.com/ipfs/interface-datastore/). A key benifit is that now the key chain can be used in browser with the [js-datastore-level](https://github.com/ipfs/js-datastore-level) implementation. + +### Cryptographic Message Syntax (CMS) + +CMS, aka [PKCS #7](https://en.wikipedia.org/wiki/PKCS) and [RFC 5652](https://tools.ietf.org/html/rfc5652), describes an encapsulation syntax for data protection. It is used to digitally sign, digest, authenticate, or encrypt arbitrary message content. Basically, `cms.encrypt` creates a DER message that can be only be read by someone holding the private key. diff --git a/src/keychain/cms.js b/src/keychain/cms.js new file mode 100644 index 0000000000..ea35a511a9 --- /dev/null +++ b/src/keychain/cms.js @@ -0,0 +1,122 @@ +'use strict' + +require('node-forge/lib/pkcs7') +require('node-forge/lib/pbe') +const forge = require('node-forge/lib/forge') +const { certificateForKey, findAsync } = require('./util') +const errcode = require('err-code') + +/** + * Cryptographic Message Syntax (aka PKCS #7) + * + * CMS describes an encapsulation syntax for data protection. It + * is used to digitally sign, digest, authenticate, or encrypt + * arbitrary message content. + * + * See RFC 5652 for all the details. + */ +class CMS { + /** + * Creates a new instance with a keychain + * + * @param {Keychain} keychain - the available keys + */ + constructor (keychain) { + if (!keychain) { + throw errcode(new Error('keychain is required'), 'ERR_KEYCHAIN_REQUIRED') + } + + this.keychain = keychain + } + + /** + * Creates some protected data. + * + * The output Buffer contains the PKCS #7 message in DER. + * + * @param {string} name - The local key name. + * @param {Buffer} plain - The data to encrypt. + * @returns {undefined} + */ + async encrypt (name, plain) { + if (!Buffer.isBuffer(plain)) { + throw errcode(new Error('Plain data must be a Buffer'), 'ERR_INVALID_PARAMS') + } + + const key = await this.keychain.findByName(name) + const pem = await this.keychain._getPrivateKey(name) + const privateKey = forge.pki.decryptRsaPrivateKey(pem, this.keychain._()) + const certificate = await certificateForKey(key, privateKey) + + // create a p7 enveloped message + const p7 = forge.pkcs7.createEnvelopedData() + p7.addRecipient(certificate) + p7.content = forge.util.createBuffer(plain) + p7.encrypt() + + // convert message to DER + const der = forge.asn1.toDer(p7.toAsn1()).getBytes() + return Buffer.from(der, 'binary') + } + + /** + * Reads some protected data. + * + * The keychain must contain one of the keys used to encrypt the data. If none of the keys + * exists, an Error is returned with the property 'missingKeys'. It is array of key ids. + * + * @param {Buffer} cmsData - The CMS encrypted data to decrypt. + * @returns {undefined} + */ + async decrypt (cmsData) { + if (!Buffer.isBuffer(cmsData)) { + throw errcode(new Error('CMS data is required'), 'ERR_INVALID_PARAMS') + } + + let cms + try { + const buf = forge.util.createBuffer(cmsData.toString('binary')) + const obj = forge.asn1.fromDer(buf) + cms = forge.pkcs7.messageFromAsn1(obj) + } catch (err) { + throw errcode(new Error('Invalid CMS: ' + err.message), 'ERR_INVALID_CMS') + } + + // Find a recipient whose key we hold. We only deal with recipient certs + // issued by ipfs (O=ipfs). + const recipients = cms.recipients + .filter(r => r.issuer.find(a => a.shortName === 'O' && a.value === 'ipfs')) + .filter(r => r.issuer.find(a => a.shortName === 'CN')) + .map(r => { + return { + recipient: r, + keyId: r.issuer.find(a => a.shortName === 'CN').value + } + }) + + const r = await findAsync(recipients, async (recipient) => { + try { + const key = await this.keychain.findById(recipient.keyId) + if (key) return true + } catch (err) { + return false + } + return false + }) + + if (!r) { + const missingKeys = recipients.map(r => r.keyId) + throw errcode(new Error('Decryption needs one of the key(s): ' + missingKeys.join(', ')), 'ERR_MISSING_KEYS', { + missingKeys + }) + } + + const key = await this.keychain.findById(r.keyId) + const pem = await this.keychain._getPrivateKey(key.name) + const privateKey = forge.pki.decryptRsaPrivateKey(pem, this.keychain._()) + cms.decrypt(r.recipient, privateKey) + return Buffer.from(cms.content.getBytes(), 'binary') + } +} + +module.exports = CMS diff --git a/src/keychain/doc/private-key.png b/src/keychain/doc/private-key.png new file mode 100644 index 0000000000..4c85dc610c Binary files /dev/null and b/src/keychain/doc/private-key.png differ diff --git a/src/keychain/doc/private-key.xml b/src/keychain/doc/private-key.xml new file mode 100644 index 0000000000..51cb8c5a9b --- /dev/null +++ b/src/keychain/doc/private-key.xml @@ -0,0 +1 @@ +7VlNb6MwEP01HLfCGBJ6bNJ2V9pdqVIP2x4dcMAKYGScJumvXxNsvkw+SmgSVe2hMs9mbL839swQA07j9U+G0vAv9XFkWKa/NuC9YVmua4n/ObApAOjCAggY8QsIVMAzeccSNCW6JD7OGgM5pREnaRP0aJJgjzcwxBhdNYfNadScNUUB1oBnD0U6+o/4PJTbssYV/guTIFQzg9Ft0TND3iJgdJnI+QwLzrd/RXeMlC250SxEPl3VIPhgwCmjlBeteD3FUU6toq1473FHb7luhhN+zAtSpzcULeXWU5RluYmQoQzLRfKNIobjtbA7CXkcCQCIZsYZXeApjSgTSEITMXIyJ1HUglBEgkQ8emJlWOCTN8w4EZTfyY6Y+H4+zWQVEo6fU+Tlc66EfwlsSynOF22KJ7loYQCvd24clHQKL8U0xpxtxBDlolIA6aBgJJ9Xldy2hMKa0ko3JB0sKA1XJIuG5Lmbc6hx/jT5ff9oaWQL50jzZsqoh4Uq3dTUtBiAF9AmxtaJAVYHM6MBmLE1Zny8EABNOaFJ9nW9sfQryfr4fN7oaJxrNOPEv8sv1ZyvSFwPxGuSLjbJNi85GzcmGCvgdQvAUQk8YUbE8nK6a7xhX7uKD7JWo8XpoEVhDEeIk7em+S6u5AxPlIiJq6PQEgWMraaJjC6Zh+Vb9Uu2bUiFw12GOGIB5pqhrXTlto9SczSomk5Dyw9IJsL1dku1C+9SKpYHR5Fvmj1VhE1D2ukbTkX3WlQsuGmErbqw4KLnE5oHBDlWWbt10K22i+xQVgiANrVhaT4g271g22xfKI3kTDQKi33d5rY7fB4Mmgxn5B3NtgNy/5D7EKOdieHcfyhcRmiGo0mZBauwW+XBe+KlzOblSoxSz7pjunvj6A8RgcpaY9Mw3tfZ1BA6n2f41IOt6puaRAucrz/AiSbUNaR/Fjxj+geAxk668PJqRLiPexX8QPuS/OjVmo84yjhleqV2CXac9o18Vnb06uEm3e01PvWW8XZfh4iZFdn+n9mQTLWSCQhcjanRntB5ElF6yl9cQl++zGpfbo7unp9VZgE9M2dJoFFdbRmc5cRarRMLLd0P3S5KnAEoGWuUaHwcTHPXhL/U2q/NjPdF+k6tIHV6J8AqeF9PBtzyZxu2HLVvaQPdlqHhShswaG0zmLQdVWsRbb+lPV5avf44Qdpm2Vo/67JLnfb+oo86RDeNKxLdHkr0208TXcXGz/pW0S066C+61SG6/S36x0TXC7VTRP9SH43VLahyzHZpc/xHY7DfUG85xWP1A2MxvPoRFz78Bw== \ No newline at end of file diff --git a/src/keychain/index.js b/src/keychain/index.js new file mode 100644 index 0000000000..2704d6268a --- /dev/null +++ b/src/keychain/index.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = require('./keychain') diff --git a/src/keychain/keychain.js b/src/keychain/keychain.js new file mode 100644 index 0000000000..77f2b2cd20 --- /dev/null +++ b/src/keychain/keychain.js @@ -0,0 +1,469 @@ +/* eslint max-nested-callbacks: ["error", 5] */ +'use strict' + +const sanitize = require('sanitize-filename') +const mergeOptions = require('merge-options') +const crypto = require('libp2p-crypto') +const DS = require('interface-datastore') +const CMS = require('./cms') +const errcode = require('err-code') + +const keyPrefix = '/pkcs8/' +const infoPrefix = '/info/' + +// NIST SP 800-132 +const NIST = { + minKeyLength: 112 / 8, + minSaltLength: 128 / 8, + minIterationCount: 1000 +} + +const defaultOptions = { + // See https://cryptosense.com/parametesr-choice-for-pbkdf2/ + dek: { + keyLength: 512 / 8, + iterationCount: 10000, + salt: 'you should override this value with a crypto secure random number', + hash: 'sha2-512' + } +} + +function validateKeyName (name) { + if (!name) return false + if (typeof name !== 'string') return false + return name === sanitize(name.trim()) +} + +/** + * Throws an error after a delay + * + * This assumes than an error indicates that the keychain is under attack. Delay returning an + * error to make brute force attacks harder. + * + * @param {string | Error} err - The error + * @private + */ +async function throwDelayed (err) { + const min = 200 + const max = 1000 + const delay = Math.random() * (max - min) + min + + await new Promise(resolve => setTimeout(resolve, delay)) + throw err +} + +/** + * Converts a key name into a datastore name. + * + * @param {string} name + * @returns {DS.Key} + * @private + */ +function DsName (name) { + return new DS.Key(keyPrefix + name) +} + +/** + * Converts a key name into a datastore info name. + * + * @param {string} name + * @returns {DS.Key} + * @private + */ +function DsInfoName (name) { + return new DS.Key(infoPrefix + name) +} + +/** + * Information about a key. + * + * @typedef {Object} KeyInfo + * + * @property {string} id - The universally unique key id. + * @property {string} name - The local key name. + */ + +/** + * Manages the lifecycle of a key. Keys are encrypted at rest using PKCS #8. + * + * A key in the store has two entries + * - '/info/*key-name*', contains the KeyInfo for the key + * - '/pkcs8/*key-name*', contains the PKCS #8 for the key + * + */ +class Keychain { + /** + * Creates a new instance of a key chain. + * + * @param {DS} store - where the key are. + * @param {object} options - ??? + */ + constructor (store, options) { + if (!store) { + throw new Error('store is required') + } + this.store = store + + this.opts = mergeOptions(defaultOptions, options) + + // Enforce NIST SP 800-132 + if (!this.opts.passPhrase || this.opts.passPhrase.length < 20) { + throw new Error('passPhrase must be least 20 characters') + } + if (this.opts.dek.keyLength < NIST.minKeyLength) { + throw new Error(`dek.keyLength must be least ${NIST.minKeyLength} bytes`) + } + if (this.opts.dek.salt.length < NIST.minSaltLength) { + throw new Error(`dek.saltLength must be least ${NIST.minSaltLength} bytes`) + } + if (this.opts.dek.iterationCount < NIST.minIterationCount) { + throw new Error(`dek.iterationCount must be least ${NIST.minIterationCount}`) + } + + // Create the derived encrypting key + const dek = crypto.pbkdf2( + this.opts.passPhrase, + this.opts.dek.salt, + this.opts.dek.iterationCount, + this.opts.dek.keyLength, + this.opts.dek.hash) + Object.defineProperty(this, '_', { value: () => dek }) + } + + /** + * Gets an object that can encrypt/decrypt protected data + * using the Cryptographic Message Syntax (CMS). + * + * CMS describes an encapsulation syntax for data protection. It + * is used to digitally sign, digest, authenticate, or encrypt + * arbitrary message content. + * + * @returns {CMS} + */ + get cms () { + return new CMS(this) + } + + /** + * Generates the options for a keychain. A random salt is produced. + * + * @returns {object} + */ + static generateOptions () { + const options = Object.assign({}, defaultOptions) + const saltLength = Math.ceil(NIST.minSaltLength / 3) * 3 // no base64 padding + options.dek.salt = crypto.randomBytes(saltLength).toString('base64') + return options + } + + /** + * Gets an object that can encrypt/decrypt protected data. + * The default options for a keychain. + * + * @returns {object} + */ + static get options () { + return defaultOptions + } + + /** + * Create a new key. + * + * @param {string} name - The local key name; cannot already exist. + * @param {string} type - One of the key types; 'rsa'. + * @param {int} size - The key size in bits. + * @returns {KeyInfo} + */ + async createKey (name, type, size) { + const self = this + + if (!validateKeyName(name) || name === 'self') { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + + if (typeof type !== 'string') { + return throwDelayed(errcode(new Error(`Invalid key type '${type}'`), 'ERR_INVALID_KEY_TYPE')) + } + + if (!Number.isSafeInteger(size)) { + return throwDelayed(errcode(new Error(`Invalid key size '${size}'`), 'ERR_INVALID_KEY_SIZE')) + } + + const dsname = DsName(name) + const exists = await self.store.has(dsname) + if (exists) return throwDelayed(errcode(new Error(`Key '${name}' already exists`), 'ERR_KEY_ALREADY_EXISTS')) + + switch (type.toLowerCase()) { + case 'rsa': + if (size < 2048) { + return throwDelayed(errcode(new Error(`Invalid RSA key size ${size}`), 'ERR_INVALID_KEY_SIZE')) + } + break + default: + break + } + + let keyInfo + try { + const keypair = await crypto.keys.generateKeyPair(type, size) + const kid = await keypair.id() + const pem = await keypair.export(this._()) + keyInfo = { + name: name, + id: kid + } + const batch = self.store.batch() + batch.put(dsname, pem) + batch.put(DsInfoName(name), JSON.stringify(keyInfo)) + + await batch.commit() + } catch (err) { + return throwDelayed(err) + } + + return keyInfo + } + + /** + * List all the keys. + * + * @returns {KeyInfo[]} + */ + async list () { + const self = this + const query = { + prefix: infoPrefix + } + + const info = [] + for await (const value of self.store.query(query)) { + info.push(JSON.parse(value.value)) + } + + return info + } + + /** + * Find a key by it's id. + * + * @param {string} id - The universally unique key identifier. + * @returns {KeyInfo} + */ + async findById (id) { + try { + const keys = await this.list() + return keys.find((k) => k.id === id) + } catch (err) { + return throwDelayed(err) + } + } + + /** + * Find a key by it's name. + * + * @param {string} name - The local key name. + * @returns {KeyInfo} + */ + async findByName (name) { + if (!validateKeyName(name)) { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + + const dsname = DsInfoName(name) + try { + const res = await this.store.get(dsname) + return JSON.parse(res.toString()) + } catch (err) { + return throwDelayed(errcode(new Error(`Key '${name}' does not exist. ${err.message}`), 'ERR_KEY_NOT_FOUND')) + } + } + + /** + * Remove an existing key. + * + * @param {string} name - The local key name; must already exist. + * @returns {KeyInfo} + */ + async removeKey (name) { + const self = this + if (!validateKeyName(name) || name === 'self') { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + const dsname = DsName(name) + const keyInfo = await self.findByName(name) + const batch = self.store.batch() + batch.delete(dsname) + batch.delete(DsInfoName(name)) + await batch.commit() + return keyInfo + } + + /** + * Rename a key + * + * @param {string} oldName - The old local key name; must already exist. + * @param {string} newName - The new local key name; must not already exist. + * @returns {KeyInfo} + */ + async renameKey (oldName, newName) { + const self = this + if (!validateKeyName(oldName) || oldName === 'self') { + return throwDelayed(errcode(new Error(`Invalid old key name '${oldName}'`), 'ERR_OLD_KEY_NAME_INVALID')) + } + if (!validateKeyName(newName) || newName === 'self') { + return throwDelayed(errcode(new Error(`Invalid new key name '${newName}'`), 'ERR_NEW_KEY_NAME_INVALID')) + } + const oldDsname = DsName(oldName) + const newDsname = DsName(newName) + const oldInfoName = DsInfoName(oldName) + const newInfoName = DsInfoName(newName) + + const exists = await self.store.has(newDsname) + if (exists) return throwDelayed(errcode(new Error(`Key '${newName}' already exists`), 'ERR_KEY_ALREADY_EXISTS')) + + try { + let res = await this.store.get(oldDsname) + const pem = res.toString() + res = await self.store.get(oldInfoName) + + const keyInfo = JSON.parse(res.toString()) + keyInfo.name = newName + const batch = self.store.batch() + batch.put(newDsname, pem) + batch.put(newInfoName, JSON.stringify(keyInfo)) + batch.delete(oldDsname) + batch.delete(oldInfoName) + await batch.commit() + return keyInfo + } catch (err) { + return throwDelayed(err) + } + } + + /** + * Export an existing key as a PEM encrypted PKCS #8 string + * + * @param {string} name - The local key name; must already exist. + * @param {string} password - The password + * @returns {string} + */ + async exportKey (name, password) { + if (!validateKeyName(name)) { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + if (!password) { + return throwDelayed(errcode(new Error('Password is required'), 'ERR_PASSWORD_REQUIRED')) + } + + const dsname = DsName(name) + try { + const res = await this.store.get(dsname) + const pem = res.toString() + const privateKey = await crypto.keys.import(pem, this._()) + return privateKey.export(password) + } catch (err) { + return throwDelayed(err) + } + } + + /** + * Import a new key from a PEM encoded PKCS #8 string + * + * @param {string} name - The local key name; must not already exist. + * @param {string} pem - The PEM encoded PKCS #8 string + * @param {string} password - The password. + * @returns {KeyInfo} + */ + async importKey (name, pem, password) { + const self = this + if (!validateKeyName(name) || name === 'self') { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + if (!pem) { + return throwDelayed(errcode(new Error('PEM encoded key is required'), 'ERR_PEM_REQUIRED')) + } + const dsname = DsName(name) + const exists = await self.store.has(dsname) + if (exists) return throwDelayed(errcode(new Error(`Key '${name}' already exists`), 'ERR_KEY_ALREADY_EXISTS')) + + let privateKey + try { + privateKey = await crypto.keys.import(pem, password) + } catch (err) { + return throwDelayed(errcode(new Error('Cannot read the key, most likely the password is wrong'), 'ERR_CANNOT_READ_KEY')) + } + + let kid + try { + kid = await privateKey.id() + pem = await privateKey.export(this._()) + } catch (err) { + return throwDelayed(err) + } + + const keyInfo = { + name: name, + id: kid + } + const batch = self.store.batch() + batch.put(dsname, pem) + batch.put(DsInfoName(name), JSON.stringify(keyInfo)) + await batch.commit() + + return keyInfo + } + + async importPeer (name, peer) { + const self = this + if (!validateKeyName(name)) { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + if (!peer || !peer.privKey) { + return throwDelayed(errcode(new Error('Peer.privKey is required'), 'ERR_MISSING_PRIVATE_KEY')) + } + + const privateKey = peer.privKey + const dsname = DsName(name) + const exists = await self.store.has(dsname) + if (exists) return throwDelayed(errcode(new Error(`Key '${name}' already exists`), 'ERR_KEY_ALREADY_EXISTS')) + + try { + const kid = await privateKey.id() + const pem = await privateKey.export(this._()) + const keyInfo = { + name: name, + id: kid + } + const batch = self.store.batch() + batch.put(dsname, pem) + batch.put(DsInfoName(name), JSON.stringify(keyInfo)) + await batch.commit() + return keyInfo + } catch (err) { + return throwDelayed(err) + } + } + + /** + * Gets the private key as PEM encoded PKCS #8 string. + * + * @param {string} name + * @returns {string} + * @private + */ + async _getPrivateKey (name) { + if (!validateKeyName(name)) { + return throwDelayed(errcode(new Error(`Invalid key name '${name}'`), 'ERR_INVALID_KEY_NAME')) + } + + try { + const dsname = DsName(name) + const res = await this.store.get(dsname) + return res.toString() + } catch (err) { + return throwDelayed(errcode(new Error(`Key '${name}' does not exist. ${err.message}`), 'ERR_KEY_NOT_FOUND')) + } + } +} + +module.exports = Keychain diff --git a/src/keychain/no-keychain.js b/src/keychain/no-keychain.js new file mode 100644 index 0000000000..6e4e612a57 --- /dev/null +++ b/src/keychain/no-keychain.js @@ -0,0 +1,33 @@ +'use strict' + +function fail () { + throw new Error('Key management requires \'--pass ...\' option') +} + +class NoKeychain { + static get options () { fail() } + + static generateOptions () { fail() } + + createKey () { fail() } + + listKeys () { fail() } + + findKeyById () { fail() } + + findKeyByName () { fail() } + + renameKey () { fail() } + + removeKey () { fail() } + + exportKey () { fail() } + + importKey () { fail() } + + importPeer () { fail() } + + get cms () { fail() } +} + +module.exports = NoKeychain diff --git a/src/keychain/util.js b/src/keychain/util.js new file mode 100644 index 0000000000..50ce4174c2 --- /dev/null +++ b/src/keychain/util.js @@ -0,0 +1,89 @@ +'use strict' + +require('node-forge/lib/x509') +const forge = require('node-forge/lib/forge') +const pki = forge.pki +exports = module.exports + +/** + * Gets a self-signed X.509 certificate for the key. + * + * The output Buffer contains the PKCS #7 message in DER. + * + * TODO: move to libp2p-crypto package + * + * @param {KeyInfo} key - The id and name of the key + * @param {RsaPrivateKey} privateKey - The naked key + * @returns {undefined} + */ +exports.certificateForKey = (key, privateKey) => { + const publicKey = pki.setRsaPublicKey(privateKey.n, privateKey.e) + const cert = pki.createCertificate() + cert.publicKey = publicKey + cert.serialNumber = '01' + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10) + const attrs = [{ + name: 'organizationName', + value: 'ipfs' + }, { + shortName: 'OU', + value: 'keystore' + }, { + name: 'commonName', + value: key.id + }] + cert.setSubject(attrs) + cert.setIssuer(attrs) + cert.setExtensions([{ + name: 'basicConstraints', + cA: true + }, { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true + }, { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true + }, { + name: 'nsCertType', + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true + }]) + // self-sign certificate + cert.sign(privateKey) + + return cert +} + +/** + * Finds the first item in a collection that is matched in the + * `asyncCompare` function. + * + * `asyncCompare` is an async function that must + * resolve to either `true` or `false`. + * + * @param {Array} array + * @param {function(*)} asyncCompare An async function that returns a boolean + */ +async function findAsync (array, asyncCompare) { + const promises = array.map(asyncCompare) + const results = await Promise.all(promises) + const index = results.findIndex(result => result) + return array[index] +} + +module.exports.findAsync = findAsync diff --git a/test/keychain/cms-interop.spec.js b/test/keychain/cms-interop.spec.js new file mode 100644 index 0000000000..8eb19f6e99 --- /dev/null +++ b/test/keychain/cms-interop.spec.js @@ -0,0 +1,74 @@ +/* eslint max-nested-callbacks: ["error", 8] */ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const expect = chai.expect +chai.use(dirtyChai) +chai.use(require('chai-string')) + +const os = require('os') +const path = require('path') +const { isNode } = require('ipfs-utils/src/env') +const FsStore = require('datastore-fs') +const LevelStore = require('datastore-level') + +const Keychain = require('../../src/keychain') + +describe('cms interop', () => { + const passPhrase = 'this is not a secure phrase' + const aliceKeyName = 'cms-interop-alice' + let ks + + before(() => { + const datastore = isNode + ? new FsStore(path.join(os.tmpdir(), 'test-keystore-1-' + Date.now())) + : new LevelStore('test-keystore-1', { db: require('level') }) + ks = new Keychain(datastore, { passPhrase: passPhrase }) + }) + + const plainData = Buffer.from('This is a message from Alice to Bob') + + it('imports openssl key', async function () { + this.timeout(10 * 1000) + const aliceKid = 'QmNzBqPwp42HZJccsLtc4ok6LjZAspckgs2du5tTmjPfFA' + const alice = `-----BEGIN ENCRYPTED PRIVATE KEY----- +MIICxjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIMhYqiVoLJMICAggA +MBQGCCqGSIb3DQMHBAhU7J9bcJPLDQSCAoDzi0dP6z97wJBs3jK2hDvZYdoScknG +QMPOnpG1LO3IZ7nFha1dta5liWX+xRFV04nmVYkkNTJAPS0xjJOG9B5Hm7wm8uTd +1rOaYKOW5S9+1sD03N+fAx9DDFtB7OyvSdw9ty6BtHAqlFk3+/APASJS12ak2pg7 +/Ei6hChSYYRS9WWGw4lmSitOBxTmrPY1HmODXkR3txR17LjikrMTd6wyky9l/u7A +CgkMnj1kn49McOBJ4gO14c9524lw9OkPatyZK39evFhx8AET73LrzCnsf74HW9Ri +dKq0FiKLVm2wAXBZqdd5ll/TPj3wmFqhhLSj/txCAGg+079gq2XPYxxYC61JNekA +ATKev5zh8x1Mf1maarKN72sD28kS/J+aVFoARIOTxbG3g+1UbYs/00iFcuIaM4IY +zB1kQUFe13iWBsJ9nfvN7TJNSVnh8NqHNbSg0SdzKlpZHHSWwOUrsKmxmw/XRVy/ +ufvN0hZQ3BuK5MZLixMWAyKc9zbZSOB7E7VNaK5Fmm85FRz0L1qRjHvoGcEIhrOt +0sjbsRvjs33J8fia0FF9nVfOXvt/67IGBKxIMF9eE91pY5wJNwmXcBk8jghTZs83 +GNmMB+cGH1XFX4cT4kUGzvqTF2zt7IP+P2cQTS1+imKm7r8GJ7ClEZ9COWWdZIcH +igg5jozKCW82JsuWSiW9tu0F/6DuvYiZwHS3OLiJP0CuLfbOaRw8Jia1RTvXEH7m +3N0/kZ8hJIK4M/t/UAlALjeNtFxYrFgsPgLxxcq7al1ruG7zBq8L/G3RnkSjtHqE +cn4oisOvxCprs4aM9UVjtZTCjfyNpX8UWwT1W3rySV+KQNhxuMy3RzmL +-----END ENCRYPTED PRIVATE KEY----- +` + const key = await ks.importKey(aliceKeyName, alice, 'mypassword') + expect(key.name).to.equal(aliceKeyName) + expect(key.id).to.equal(aliceKid) + }) + + it('decrypts node-forge example', async () => { + const example = ` +MIIBcwYJKoZIhvcNAQcDoIIBZDCCAWACAQAxgfowgfcCAQAwYDBbMQ0wCwYDVQQK +EwRpcGZzMREwDwYDVQQLEwhrZXlzdG9yZTE3MDUGA1UEAxMuUW1OekJxUHdwNDJI +WkpjY3NMdGM0b2s2TGpaQXNwY2tnczJkdTV0VG1qUGZGQQIBATANBgkqhkiG9w0B +AQEFAASBgLKXCZQYmMLuQ8m0Ex/rr3KNK+Q2+QG1zIbIQ9MFPUNQ7AOgGOHyL40k +d1gr188EHuiwd90PafZoQF9VRSX9YtwGNqAE8+LD8VaITxCFbLGRTjAqeOUHR8cO +knU1yykWGkdlbclCuu0NaAfmb8o0OX50CbEKZB7xmsv8tnqn0H0jMF4GCSqGSIb3 +DQEHATAdBglghkgBZQMEASoEEP/PW1JWehQx6/dsLkp/Mf+gMgQwFM9liLTqC56B +nHILFmhac/+a/StQOKuf9dx5qXeGvt9LnwKuGGSfNX4g+dTkoa6N +` + const plain = await ks.cms.decrypt(Buffer.from(example, 'base64')) + expect(plain).to.exist() + expect(plain.toString()).to.equal(plainData.toString()) + }) +}) diff --git a/test/keychain/keychain-api.spec.js b/test/keychain/keychain-api.spec.js new file mode 100644 index 0000000000..3fc66708c7 --- /dev/null +++ b/test/keychain/keychain-api.spec.js @@ -0,0 +1,394 @@ +/* eslint max-nested-callbacks: ["error", 8] */ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const fail = expect.fail +chai.use(require('dirty-chai')) +chai.use(require('chai-string')) + +const os = require('os') +const path = require('path') +const { isNode } = require('ipfs-utils/src/env') +const FsStore = require('datastore-fs') +const LevelStore = require('datastore-level') +const Keychain = require('../../src/keychain') +const PeerId = require('peer-id') + +describe('keychain api', () => { + const passPhrase = 'this is not a secure phrase' + const rsaKeyName = 'tajné jméno' + const renamedRsaKeyName = 'ชื่อลับ' + let rsaKeyInfo + let emptyKeystore + let ks + let datastore1, datastore2 + + before(() => { + datastore1 = isNode + ? new FsStore(path.join(os.tmpdir(), 'test-keystore-1-' + Date.now())) + : new LevelStore('test-keystore-1', { db: require('level') }) + datastore2 = isNode + ? new FsStore(path.join(os.tmpdir(), 'test-keystore-2-' + Date.now())) + : new LevelStore('test-keystore-2', { db: require('level') }) + + ks = new Keychain(datastore2, { passPhrase: passPhrase }) + emptyKeystore = new Keychain(datastore1, { passPhrase: passPhrase }) + }) + + it('needs a pass phrase to encrypt a key', () => { + expect(() => new Keychain(datastore2)).to.throw() + }) + + it('needs a NIST SP 800-132 non-weak pass phrase', () => { + expect(() => new Keychain(datastore2, { passPhrase: '< 20 character' })).to.throw() + }) + + it('needs a store to persist a key', () => { + expect(() => new Keychain(null, { passPhrase: passPhrase })).to.throw() + }) + + it('has default options', () => { + expect(Keychain.options).to.exist() + }) + + it('needs a supported hashing alorithm', () => { + const ok = new Keychain(datastore2, { passPhrase: passPhrase, dek: { hash: 'sha2-256' } }) + expect(ok).to.exist() + expect(() => new Keychain(datastore2, { passPhrase: passPhrase, dek: { hash: 'my-hash' } })).to.throw() + }) + + it('can generate options', () => { + const options = Keychain.generateOptions() + options.passPhrase = passPhrase + const chain = new Keychain(datastore2, options) + expect(chain).to.exist() + }) + + describe('key name', () => { + it('is a valid filename and non-ASCII', async () => { + const errors = await Promise.all([ + ks.removeKey('../../nasty').then(fail, err => err), + ks.removeKey('').then(fail, err => err), + ks.removeKey(' ').then(fail, err => err), + ks.removeKey(null).then(fail, err => err), + ks.removeKey(undefined).then(fail, err => err) + ]) + + expect(errors).to.have.length(5) + errors.forEach(error => { + expect(error).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + }) + }) + + describe('key', () => { + it('can be an RSA key', async () => { + rsaKeyInfo = await ks.createKey(rsaKeyName, 'rsa', 2048) + expect(rsaKeyInfo).to.exist() + expect(rsaKeyInfo).to.have.property('name', rsaKeyName) + expect(rsaKeyInfo).to.have.property('id') + }) + + it('is encrypted PEM encoded PKCS #8', async () => { + const pem = await ks._getPrivateKey(rsaKeyName) + return expect(pem).to.startsWith('-----BEGIN ENCRYPTED PRIVATE KEY-----') + }) + + it('throws if an invalid private key name is given', async () => { + const err = await ks._getPrivateKey(undefined).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('throws if a private key cant be found', async () => { + const err = await ks._getPrivateKey('not real').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_KEY_NOT_FOUND') + }) + + it('does not overwrite existing key', async () => { + const err = await ks.createKey(rsaKeyName, 'rsa', 2048).then(fail, err => err) + expect(err).to.have.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot create the "self" key', async () => { + const err = await ks.createKey('self', 'rsa', 2048).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('should validate name is string', async () => { + const err = await ks.createKey(5, 'rsa', 2048).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('should validate type is string', async () => { + const err = await ks.createKey('TEST' + Date.now(), null, 2048).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_TYPE') + }) + + it('should validate size is integer', async () => { + const err = await ks.createKey('TEST' + Date.now(), 'rsa', 'string').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_SIZE') + }) + + describe('implements NIST SP 800-131A', () => { + it('disallows RSA length < 2048', async () => { + const err = await ks.createKey('bad-nist-rsa', 'rsa', 1024).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_SIZE') + }) + }) + }) + + describe('query', () => { + it('finds all existing keys', async () => { + const keys = await ks.list() + expect(keys).to.exist() + const mykey = keys.find((k) => k.name.normalize() === rsaKeyName.normalize()) + expect(mykey).to.exist() + }) + + it('finds a key by name', async () => { + const key = await ks.findByName(rsaKeyName) + expect(key).to.exist() + expect(key).to.deep.equal(rsaKeyInfo) + }) + + it('finds a key by id', async () => { + const key = await ks.findById(rsaKeyInfo.id) + expect(key).to.exist() + expect(key).to.deep.equal(rsaKeyInfo) + }) + + it('returns the key\'s name and id', async () => { + const keys = await ks.list() + expect(keys).to.exist() + keys.forEach((key) => { + expect(key).to.have.property('name') + expect(key).to.have.property('id') + }) + }) + }) + + describe('CMS protected data', () => { + const plainData = Buffer.from('This is a message from Alice to Bob') + let cms + + it('service is available', () => { + expect(ks).to.have.property('cms') + }) + + it('requires a key', async () => { + const err = await ks.cms.encrypt('no-key', plainData).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_KEY_NOT_FOUND') + }) + + it('requires plain data as a Buffer', async () => { + const err = await ks.cms.encrypt(rsaKeyName, 'plain data').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_PARAMS') + }) + + it('encrypts', async () => { + cms = await ks.cms.encrypt(rsaKeyName, plainData) + expect(cms).to.exist() + expect(cms).to.be.instanceOf(Buffer) + }) + + it('is a PKCS #7 message', async () => { + const err = await ks.cms.decrypt('not CMS').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_PARAMS') + }) + + it('is a PKCS #7 binary message', async () => { + const err = await ks.cms.decrypt(plainData).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_CMS') + }) + + it('cannot be read without the key', async () => { + const err = await emptyKeystore.cms.decrypt(cms).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('missingKeys') + expect(err.missingKeys).to.eql([rsaKeyInfo.id]) + expect(err).to.have.property('code', 'ERR_MISSING_KEYS') + }) + + it('can be read with the key', async () => { + const plain = await ks.cms.decrypt(cms) + expect(plain).to.exist() + expect(plain.toString()).to.equal(plainData.toString()) + }) + }) + + describe('exported key', () => { + let pemKey + + it('requires the password', async () => { + const err = await ks.exportKey(rsaKeyName).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_PASSWORD_REQUIRED') + }) + + it('requires the key name', async () => { + const err = await ks.exportKey(undefined, 'password').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('is a PKCS #8 encrypted pem', async () => { + pemKey = await ks.exportKey(rsaKeyName, 'password') + expect(pemKey).to.startsWith('-----BEGIN ENCRYPTED PRIVATE KEY-----') + }) + + it('can be imported', async () => { + const key = await ks.importKey('imported-key', pemKey, 'password') + expect(key.name).to.equal('imported-key') + expect(key.id).to.equal(rsaKeyInfo.id) + }) + + it('requires the pem', async () => { + const err = await ks.importKey('imported-key', undefined, 'password').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_PEM_REQUIRED') + }) + + it('cannot be imported as an existing key name', async () => { + const err = await ks.importKey(rsaKeyName, pemKey, 'password').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot be imported with the wrong password', async () => { + const err = await ks.importKey('a-new-name-for-import', pemKey, 'not the password').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_CANNOT_READ_KEY') + }) + }) + + describe('peer id', () => { + const alicePrivKey = 'CAASpgkwggSiAgEAAoIBAQC2SKo/HMFZeBml1AF3XijzrxrfQXdJzjePBZAbdxqKR1Mc6juRHXij6HXYPjlAk01BhF1S3Ll4Lwi0cAHhggf457sMg55UWyeGKeUv0ucgvCpBwlR5cQ020i0MgzjPWOLWq1rtvSbNcAi2ZEVn6+Q2EcHo3wUvWRtLeKz+DZSZfw2PEDC+DGPJPl7f8g7zl56YymmmzH9liZLNrzg/qidokUv5u1pdGrcpLuPNeTODk0cqKB+OUbuKj9GShYECCEjaybJDl9276oalL9ghBtSeEv20kugatTvYy590wFlJkkvyl+nPxIH0EEYMKK9XRWlu9XYnoSfboiwcv8M3SlsjAgMBAAECggEAZtju/bcKvKFPz0mkHiaJcpycy9STKphorpCT83srBVQi59CdFU6Mj+aL/xt0kCPMVigJw8P3/YCEJ9J+rS8BsoWE+xWUEsJvtXoT7vzPHaAtM3ci1HZd302Mz1+GgS8Epdx+7F5p80XAFLDUnELzOzKftvWGZmWfSeDnslwVONkL/1VAzwKy7Ce6hk4SxRE7l2NE2OklSHOzCGU1f78ZzVYKSnS5Ag9YrGjOAmTOXDbKNKN/qIorAQ1bovzGoCwx3iGIatQKFOxyVCyO1PsJYT7JO+kZbhBWRRE+L7l+ppPER9bdLFxs1t5CrKc078h+wuUr05S1P1JjXk68pk3+kQKBgQDeK8AR11373Mzib6uzpjGzgNRMzdYNuExWjxyxAzz53NAR7zrPHvXvfIqjDScLJ4NcRO2TddhXAfZoOPVH5k4PJHKLBPKuXZpWlookCAyENY7+Pd55S8r+a+MusrMagYNljb5WbVTgN8cgdpim9lbbIFlpN6SZaVjLQL3J8TWH6wKBgQDSChzItkqWX11CNstJ9zJyUE20I7LrpyBJNgG1gtvz3ZMUQCn3PxxHtQzN9n1P0mSSYs+jBKPuoSyYLt1wwe10/lpgL4rkKWU3/m1Myt0tveJ9WcqHh6tzcAbb/fXpUFT/o4SWDimWkPkuCb+8j//2yiXk0a/T2f36zKMuZvujqQKBgC6B7BAQDG2H2B/ijofp12ejJU36nL98gAZyqOfpLJ+FeMz4TlBDQ+phIMhnHXA5UkdDapQ+zA3SrFk+6yGk9Vw4Hf46B+82SvOrSbmnMa+PYqKYIvUzR4gg34rL/7AhwnbEyD5hXq4dHwMNsIDq+l2elPjwm/U9V0gdAl2+r50HAoGALtsKqMvhv8HucAMBPrLikhXP/8um8mMKFMrzfqZ+otxfHzlhI0L08Bo3jQrb0Z7ByNY6M8epOmbCKADsbWcVre/AAY0ZkuSZK/CaOXNX/AhMKmKJh8qAOPRY02LIJRBCpfS4czEdnfUhYV/TYiFNnKRj57PPYZdTzUsxa/yVTmECgYBr7slQEjb5Onn5mZnGDh+72BxLNdgwBkhO0OCdpdISqk0F0Pxby22DFOKXZEpiyI9XYP1C8wPiJsShGm2yEwBPWXnrrZNWczaVuCbXHrZkWQogBDG3HGXNdU4MAWCyiYlyinIBpPpoAJZSzpGLmWbMWh28+RJS6AQX6KHrK1o2uw==' + let alice + + before(async function () { + const encoded = Buffer.from(alicePrivKey, 'base64') + alice = await PeerId.createFromPrivKey(encoded) + }) + + it('private key can be imported', async () => { + const key = await ks.importPeer('alice', alice) + expect(key.name).to.equal('alice') + expect(key.id).to.equal(alice.toB58String()) + }) + + it('private key import requires a valid name', async () => { + const err = await ks.importPeer(undefined, alice).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('private key import requires the peer', async () => { + const err = await ks.importPeer('alice').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_MISSING_PRIVATE_KEY') + }) + + it('key id exists', async () => { + const key = await ks.findById(alice.toB58String()) + expect(key).to.exist() + expect(key).to.have.property('name', 'alice') + expect(key).to.have.property('id', alice.toB58String()) + }) + + it('key name exists', async () => { + const key = await ks.findByName('alice') + expect(key).to.exist() + expect(key).to.have.property('name', 'alice') + expect(key).to.have.property('id', alice.toB58String()) + }) + }) + + describe('rename', () => { + it('requires an existing key name', async () => { + const err = await ks.renameKey('not-there', renamedRsaKeyName).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_NOT_FOUND') + }) + + it('requires a valid new key name', async () => { + const err = await ks.renameKey(rsaKeyName, '..\not-valid').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_NEW_KEY_NAME_INVALID') + }) + + it('does not overwrite existing key', async () => { + const err = await ks.renameKey(rsaKeyName, rsaKeyName).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot create the "self" key', async () => { + const err = await ks.renameKey(rsaKeyName, 'self').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_NEW_KEY_NAME_INVALID') + }) + + it('removes the existing key name', async () => { + const key = await ks.renameKey(rsaKeyName, renamedRsaKeyName) + expect(key).to.exist() + expect(key).to.have.property('name', renamedRsaKeyName) + expect(key).to.have.property('id', rsaKeyInfo.id) + // Try to find the changed key + const err = await ks.findByName(rsaKeyName).then(fail, err => err) + expect(err).to.exist() + }) + + it('creates the new key name', async () => { + const key = await ks.findByName(renamedRsaKeyName) + expect(key).to.exist() + expect(key).to.have.property('name', renamedRsaKeyName) + }) + + it('does not change the key ID', async () => { + const key = await ks.findByName(renamedRsaKeyName) + expect(key).to.exist() + expect(key).to.have.property('name', renamedRsaKeyName) + expect(key).to.have.property('id', rsaKeyInfo.id) + }) + + it('throws with invalid key names', async () => { + const err = await ks.findByName(undefined).then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + }) + + describe('key removal', () => { + it('cannot remove the "self" key', async () => { + const err = await ks.removeKey('self').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('cannot remove an unknown key', async () => { + const err = await ks.removeKey('not-there').then(fail, err => err) + expect(err).to.exist() + expect(err).to.have.property('code', 'ERR_KEY_NOT_FOUND') + }) + + it('can remove a known key', async () => { + const key = await ks.removeKey(renamedRsaKeyName) + expect(key).to.exist() + expect(key).to.have.property('name', renamedRsaKeyName) + expect(key).to.have.property('id', rsaKeyInfo.id) + }) + }) +}) diff --git a/test/keychain/keychain.spec.js b/test/keychain/keychain.spec.js new file mode 100644 index 0000000000..9b3cfc1adb --- /dev/null +++ b/test/keychain/keychain.spec.js @@ -0,0 +1,38 @@ +'use strict' +/* eslint-env mocha */ + +const chai = require('chai') +chai.use(require('dirty-chai')) +const { expect } = chai + +const peerUtils = require('../utils/creators/peer') + +describe('libp2p.keychain', () => { + it('needs a passphrase to be used, otherwise throws an error', async () => { + const [libp2p] = await peerUtils.createPeer({ + started: false + }) + + try { + await libp2p.keychain.createKey('keyName', 'rsa', 2048) + } catch (err) { + expect(err).to.exist() + return + } + throw new Error('should throw an error using the keychain if no passphrase provided') + }) + + it('can be used if a passphrase is provided', async () => { + const [libp2p] = await peerUtils.createPeer({ + started: false, + config: { + keychain: { + pass: '12345678901234567890' + } + } + }) + + const kInfo = await libp2p.keychain.createKey('keyName', 'rsa', 2048) + expect(kInfo).to.exist() + }) +}) diff --git a/test/keychain/peerid.spec.js b/test/keychain/peerid.spec.js new file mode 100644 index 0000000000..4360e5388d --- /dev/null +++ b/test/keychain/peerid.spec.js @@ -0,0 +1,69 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const expect = chai.expect +chai.use(dirtyChai) +const PeerId = require('peer-id') +const multihash = require('multihashes') +const crypto = require('libp2p-crypto') +const rsaUtils = require('libp2p-crypto/src/keys/rsa-utils') +const rsaClass = require('libp2p-crypto/src/keys/rsa-class') + +const sample = { + id: '122019318b6e5e0cf93a2314bf01269a2cc23cd3dcd452d742cdb9379d8646f6e4a9', + privKey: 'CAASpgkwggSiAgEAAoIBAQC2SKo/HMFZeBml1AF3XijzrxrfQXdJzjePBZAbdxqKR1Mc6juRHXij6HXYPjlAk01BhF1S3Ll4Lwi0cAHhggf457sMg55UWyeGKeUv0ucgvCpBwlR5cQ020i0MgzjPWOLWq1rtvSbNcAi2ZEVn6+Q2EcHo3wUvWRtLeKz+DZSZfw2PEDC+DGPJPl7f8g7zl56YymmmzH9liZLNrzg/qidokUv5u1pdGrcpLuPNeTODk0cqKB+OUbuKj9GShYECCEjaybJDl9276oalL9ghBtSeEv20kugatTvYy590wFlJkkvyl+nPxIH0EEYMKK9XRWlu9XYnoSfboiwcv8M3SlsjAgMBAAECggEAZtju/bcKvKFPz0mkHiaJcpycy9STKphorpCT83srBVQi59CdFU6Mj+aL/xt0kCPMVigJw8P3/YCEJ9J+rS8BsoWE+xWUEsJvtXoT7vzPHaAtM3ci1HZd302Mz1+GgS8Epdx+7F5p80XAFLDUnELzOzKftvWGZmWfSeDnslwVONkL/1VAzwKy7Ce6hk4SxRE7l2NE2OklSHOzCGU1f78ZzVYKSnS5Ag9YrGjOAmTOXDbKNKN/qIorAQ1bovzGoCwx3iGIatQKFOxyVCyO1PsJYT7JO+kZbhBWRRE+L7l+ppPER9bdLFxs1t5CrKc078h+wuUr05S1P1JjXk68pk3+kQKBgQDeK8AR11373Mzib6uzpjGzgNRMzdYNuExWjxyxAzz53NAR7zrPHvXvfIqjDScLJ4NcRO2TddhXAfZoOPVH5k4PJHKLBPKuXZpWlookCAyENY7+Pd55S8r+a+MusrMagYNljb5WbVTgN8cgdpim9lbbIFlpN6SZaVjLQL3J8TWH6wKBgQDSChzItkqWX11CNstJ9zJyUE20I7LrpyBJNgG1gtvz3ZMUQCn3PxxHtQzN9n1P0mSSYs+jBKPuoSyYLt1wwe10/lpgL4rkKWU3/m1Myt0tveJ9WcqHh6tzcAbb/fXpUFT/o4SWDimWkPkuCb+8j//2yiXk0a/T2f36zKMuZvujqQKBgC6B7BAQDG2H2B/ijofp12ejJU36nL98gAZyqOfpLJ+FeMz4TlBDQ+phIMhnHXA5UkdDapQ+zA3SrFk+6yGk9Vw4Hf46B+82SvOrSbmnMa+PYqKYIvUzR4gg34rL/7AhwnbEyD5hXq4dHwMNsIDq+l2elPjwm/U9V0gdAl2+r50HAoGALtsKqMvhv8HucAMBPrLikhXP/8um8mMKFMrzfqZ+otxfHzlhI0L08Bo3jQrb0Z7ByNY6M8epOmbCKADsbWcVre/AAY0ZkuSZK/CaOXNX/AhMKmKJh8qAOPRY02LIJRBCpfS4czEdnfUhYV/TYiFNnKRj57PPYZdTzUsxa/yVTmECgYBr7slQEjb5Onn5mZnGDh+72BxLNdgwBkhO0OCdpdISqk0F0Pxby22DFOKXZEpiyI9XYP1C8wPiJsShGm2yEwBPWXnrrZNWczaVuCbXHrZkWQogBDG3HGXNdU4MAWCyiYlyinIBpPpoAJZSzpGLmWbMWh28+RJS6AQX6KHrK1o2uw==', + pubKey: 'CAASpgIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2SKo/HMFZeBml1AF3XijzrxrfQXdJzjePBZAbdxqKR1Mc6juRHXij6HXYPjlAk01BhF1S3Ll4Lwi0cAHhggf457sMg55UWyeGKeUv0ucgvCpBwlR5cQ020i0MgzjPWOLWq1rtvSbNcAi2ZEVn6+Q2EcHo3wUvWRtLeKz+DZSZfw2PEDC+DGPJPl7f8g7zl56YymmmzH9liZLNrzg/qidokUv5u1pdGrcpLuPNeTODk0cqKB+OUbuKj9GShYECCEjaybJDl9276oalL9ghBtSeEv20kugatTvYy590wFlJkkvyl+nPxIH0EEYMKK9XRWlu9XYnoSfboiwcv8M3SlsjAgMBAAE=' +} + +describe('peer ID', () => { + let peer + let publicKeyDer // a buffer + + before(async () => { + const encoded = Buffer.from(sample.privKey, 'base64') + peer = await PeerId.createFromPrivKey(encoded) + }) + + it('decoded public key', async () => { + // get protobuf version of the public key + const publicKeyProtobuf = peer.marshalPubKey() + const publicKey = crypto.keys.unmarshalPublicKey(publicKeyProtobuf) + publicKeyDer = publicKey.marshal() + + // get protobuf version of the private key + const privateKeyProtobuf = peer.marshalPrivKey() + const key = await crypto.keys.unmarshalPrivateKey(privateKeyProtobuf) + expect(key).to.exist() + }) + + it('encoded public key with DER', async () => { + const jwk = rsaUtils.pkixToJwk(publicKeyDer) + const rsa = new rsaClass.RsaPublicKey(jwk) + const keyId = await rsa.hash() + const kids = multihash.toB58String(keyId) + expect(kids).to.equal(peer.toB58String()) + }) + + it('encoded public key with JWT', async () => { + const jwk = { + kty: 'RSA', + n: 'tkiqPxzBWXgZpdQBd14o868a30F3Sc43jwWQG3caikdTHOo7kR14o-h12D45QJNNQYRdUty5eC8ItHAB4YIH-Oe7DIOeVFsnhinlL9LnILwqQcJUeXENNtItDIM4z1ji1qta7b0mzXAItmRFZ-vkNhHB6N8FL1kbS3is_g2UmX8NjxAwvgxjyT5e3_IO85eemMpppsx_ZYmSza84P6onaJFL-btaXRq3KS7jzXkzg5NHKigfjlG7io_RkoWBAghI2smyQ5fdu-qGpS_YIQbUnhL9tJLoGrU72MufdMBZSZJL8pfpz8SB9BBGDCivV0VpbvV2J6En26IsHL_DN0pbIw', + e: 'AQAB', + alg: 'RS256', + kid: '2011-04-29' + } + const rsa = new rsaClass.RsaPublicKey(jwk) + const keyId = await rsa.hash() + const kids = multihash.toB58String(keyId) + expect(kids).to.equal(peer.toB58String()) + }) + + it('decoded private key', async () => { + // get protobuf version of the private key + const privateKeyProtobuf = peer.marshalPrivKey() + const key = await crypto.keys.unmarshalPrivateKey(privateKeyProtobuf) + expect(key).to.exist() + }) +})