mirror of
https://github.com/fluencelabs/js-libp2p
synced 2025-07-31 16:32:01 +00:00
chore: integrate libp2p-keychain into js-libp2p (#633)
Integrates the libp2p-keychain codebase into this repo
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { MemoryDatastore } = require('interface-datastore')
|
||||
const mergeOptions = require('merge-options')
|
||||
const Constants = require('./constants')
|
||||
|
||||
@@ -17,6 +18,9 @@ const DefaultConfig = {
|
||||
maxDialsPerPeer: Constants.MAX_PER_PEER_DIALS,
|
||||
dialTimeout: Constants.DIAL_TIMEOUT
|
||||
},
|
||||
keychain: {
|
||||
datastore: new MemoryDatastore()
|
||||
},
|
||||
metrics: {
|
||||
enabled: false
|
||||
},
|
||||
|
31
src/index.js
31
src/index.js
@@ -19,6 +19,7 @@ const AddressManager = require('./address-manager')
|
||||
const ConnectionManager = require('./connection-manager')
|
||||
const Circuit = require('./circuit')
|
||||
const Dialer = require('./dialer')
|
||||
const Keychain = require('./keychain')
|
||||
const Metrics = require('./metrics')
|
||||
const TransportManager = require('./transport-manager')
|
||||
const Upgrader = require('./upgrader')
|
||||
@@ -74,6 +75,22 @@ class Libp2p extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
// Create keychain
|
||||
if (this._options.keychain.pass) {
|
||||
log('creating keychain')
|
||||
|
||||
const datastore = this._options.keychain.datastore
|
||||
const keychainOpts = Keychain.generateOptions()
|
||||
|
||||
this.keychain = new Keychain(datastore, {
|
||||
passPhrase: this._options.keychain.pass,
|
||||
...keychainOpts,
|
||||
...this._options.keychain
|
||||
})
|
||||
|
||||
log('keychain constructed')
|
||||
}
|
||||
|
||||
// Setup the Upgrader
|
||||
this.upgrader = new Upgrader({
|
||||
localPeer: this.peerId,
|
||||
@@ -249,6 +266,20 @@ class Libp2p extends EventEmitter {
|
||||
log('libp2p has stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load keychain keys from the datastore.
|
||||
* Imports the private key as 'self', if needed.
|
||||
* @async
|
||||
* @returns {void}
|
||||
*/
|
||||
async loadKeychain () {
|
||||
try {
|
||||
await this.keychain.findKeyByName('self')
|
||||
} catch (err) {
|
||||
await this.keychain.importPeer('self', this.peerId)
|
||||
}
|
||||
}
|
||||
|
||||
isStarted () {
|
||||
return this._isStarted
|
||||
}
|
||||
|
@@ -1,20 +1,7 @@
|
||||
# js-libp2p-keychain
|
||||
|
||||
[](http://protocol.ai)
|
||||
[](http://libp2p.io/)
|
||||
[](http://webchat.freenode.net/?channels=%23libp2p)
|
||||
[](https://discuss.libp2p.io)
|
||||
[](https://codecov.io/gh/libp2p/js-libp2p-keychain)
|
||||
[](https://travis-ci.com/libp2p/js-libp2p-keychain)
|
||||
[](https://david-dm.org/libp2p/js-libp2p-keychain)
|
||||
[](https://github.com/feross/standard)
|
||||
|
||||
> A secure key chain for libp2p in JavaScript
|
||||
|
||||
## Lead Maintainer
|
||||
|
||||
[Vasco Santos](https://github.com/vasco-santos).
|
||||
|
||||
## Features
|
||||
|
||||
- Manages the lifecycle of a key
|
||||
@@ -26,49 +13,6 @@
|
||||
- Uses PKCS 7: CMS (aka RFC 5652) to provide cryptographically protected messages
|
||||
- Delays reporting errors to slow down brute force attacks
|
||||
|
||||
## Table of Contents
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install --save libp2p-keychain
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```js
|
||||
const Keychain = require('libp2p-keychain')
|
||||
const FsStore = require('datastore-fs')
|
||||
|
||||
const datastore = new FsStore('./a-keystore')
|
||||
const opts = {
|
||||
passPhrase: 'some long easily remembered phrase'
|
||||
}
|
||||
const keychain = new Keychain(datastore, opts)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Managing a key
|
||||
|
||||
- `async createKey (name, type, size)`
|
||||
- `async renameKey (oldName, newName)`
|
||||
- `async removeKey (name)`
|
||||
- `async exportKey (name, password)`
|
||||
- `async importKey (name, pem, password)`
|
||||
- `async importPeer (name, peer)`
|
||||
|
||||
A naming service for a key
|
||||
|
||||
- `async listKeys ()`
|
||||
- `async findKeyById (id)`
|
||||
- `async findKeyByName (name)`
|
||||
|
||||
Cryptographically protected messages
|
||||
|
||||
- `async cms.encrypt (name, plain)`
|
||||
- `async cms.decrypt (cmsData)`
|
||||
|
||||
### 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.
|
||||
@@ -109,15 +53,3 @@ The actual physical storage of an encrypted key is left to implementations of [i
|
||||
### 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.
|
||||
|
||||
## Contribute
|
||||
|
||||
Feel free to join in. All welcome. Open an [issue](https://github.com/libp2p/js-libp2p-keychain/issues)!
|
||||
|
||||
This repository falls under the IPFS [Code of Conduct](https://github.com/ipfs/community/blob/master/code-of-conduct.md).
|
||||
|
||||
[](https://github.com/ipfs/community/blob/master/CONTRIBUTING.md)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
@@ -1,3 +1,469 @@
|
||||
/* eslint max-nested-callbacks: ["error", 5] */
|
||||
'use strict'
|
||||
|
||||
module.exports = require('./keychain')
|
||||
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 listKeys () {
|
||||
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 findKeyById (id) {
|
||||
try {
|
||||
const keys = await this.listKeys()
|
||||
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 findKeyByName (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.findKeyByName(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
|
||||
|
@@ -1,469 +0,0 @@
|
||||
/* 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
|
||||
|
||||
const opts = mergeOptions(defaultOptions, options)
|
||||
|
||||
// Enforce NIST SP 800-132
|
||||
if (!opts.passPhrase || opts.passPhrase.length < 20) {
|
||||
throw new Error('passPhrase must be least 20 characters')
|
||||
}
|
||||
if (opts.dek.keyLength < NIST.minKeyLength) {
|
||||
throw new Error(`dek.keyLength must be least ${NIST.minKeyLength} bytes`)
|
||||
}
|
||||
if (opts.dek.salt.length < NIST.minSaltLength) {
|
||||
throw new Error(`dek.saltLength must be least ${NIST.minSaltLength} bytes`)
|
||||
}
|
||||
if (opts.dek.iterationCount < NIST.minIterationCount) {
|
||||
throw new Error(`dek.iterationCount must be least ${NIST.minIterationCount}`)
|
||||
}
|
||||
|
||||
// Create the derived encrypting key
|
||||
const dek = crypto.pbkdf2(
|
||||
opts.passPhrase,
|
||||
opts.dek.salt,
|
||||
opts.dek.iterationCount,
|
||||
opts.dek.keyLength,
|
||||
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 listKeys () {
|
||||
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 findKeyById (id) {
|
||||
try {
|
||||
const keys = await this.listKeys()
|
||||
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 findKeyByName (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.findKeyByName(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
|
Reference in New Issue
Block a user