diff --git a/package.json b/package.json index 1335bdc286..a65dcdd3d0 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,7 +86,10 @@ "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.1.0", + "datastore-level": "^1.1.0", "delay": "^4.3.0", "dirty-chai": "^2.0.1", "interop-libp2p": "libp2p/interop#chore/update-libp2p-daemon-with-peerstore", @@ -92,6 +97,7 @@ "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 +105,19 @@ "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", + "promisify-es6": "^1.0.3", + "rimraf": "^3.0.2", "sinon": "^9.0.2" }, "contributors": [ diff --git a/src/keychain/README.md b/src/keychain/README.md new file mode 100644 index 0000000000..37829b48a7 --- /dev/null +++ b/src/keychain/README.md @@ -0,0 +1,123 @@ +# js-libp2p-keychain + +[![](https://img.shields.io/badge/made%20by-Protocol%20Labs-blue.svg?style=flat-square)](http://protocol.ai) +[![](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![](https://img.shields.io/badge/freenode-%23libp2p-yellow.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23libp2p) +[![Discourse posts](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg)](https://discuss.libp2p.io) +[![](https://img.shields.io/codecov/c/github/libp2p/js-libp2p-keychain.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p-keychain) +[![](https://img.shields.io/travis/libp2p/js-libp2p-keychain.svg?style=flat-square)](https://travis-ci.com/libp2p/js-libp2p-keychain) +[![Dependency Status](https://david-dm.org/libp2p/js-libp2p-keychain.svg?style=flat-square)](https://david-dm.org/libp2p/js-libp2p-keychain) +[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](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 +- 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 + +## 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. + +```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. + +## 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://cdn.rawgit.com/jbenet/contribute-ipfs-gif/master/img/contribute.gif)](https://github.com/ipfs/community/blob/master/CONTRIBUTING.md) + +## License + +[MIT](LICENSE) diff --git a/src/keychain/cms.js b/src/keychain/cms.js new file mode 100644 index 0000000000..9bec4b9451 --- /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.findKeyByName(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.findKeyById(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.findKeyById(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..aae7897224 --- /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 + + 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 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/browser.js b/test/keychain/browser.js new file mode 100644 index 0000000000..02222fb3b3 --- /dev/null +++ b/test/keychain/browser.js @@ -0,0 +1,27 @@ +/* eslint-env mocha */ +'use strict' + +const LevelStore = require('datastore-level') + +describe('browser', () => { + const datastore1 = new LevelStore('test-keystore-1', { db: require('level') }) + const datastore2 = new LevelStore('test-keystore-2', { db: require('level') }) + + before(() => { + return Promise.all([ + datastore1.open(), + datastore2.open() + ]) + }) + + after(() => { + return Promise.all([ + datastore1.close(), + datastore2.close() + ]) + }) + + require('./keychain.spec')(datastore1, datastore2) + require('./cms-interop')(datastore2) + require('./peerid') +}) diff --git a/test/keychain/cms-interop.js b/test/keychain/cms-interop.js new file mode 100644 index 0000000000..e32764c9c6 --- /dev/null +++ b/test/keychain/cms-interop.js @@ -0,0 +1,66 @@ +/* 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 Keychain = require('../../src/keychain') + +module.exports = (datastore) => { + describe('cms interop', () => { + const passPhrase = 'this is not a secure phrase' + const aliceKeyName = 'cms-interop-alice' + let ks + + before(() => { + 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.spec.js b/test/keychain/keychain.spec.js new file mode 100644 index 0000000000..c39a7f4868 --- /dev/null +++ b/test/keychain/keychain.spec.js @@ -0,0 +1,383 @@ +/* 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 Keychain = require('../../src/keychain') +const PeerId = require('peer-id') + +module.exports = (datastore1, datastore2) => { + describe('keychain', () => { + const passPhrase = 'this is not a secure phrase' + const rsaKeyName = 'tajné jméno' + const renamedRsaKeyName = 'ชื่อลับ' + let rsaKeyInfo + let emptyKeystore + let ks + + before((done) => { + ks = new Keychain(datastore2, { passPhrase: passPhrase }) + emptyKeystore = new Keychain(datastore1, { passPhrase: passPhrase }) + done() + }) + + 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.listKeys() + 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.findKeyByName(rsaKeyName) + expect(key).to.exist() + expect(key).to.deep.equal(rsaKeyInfo) + }) + + it('finds a key by id', async () => { + const key = await ks.findKeyById(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.listKeys() + 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.findKeyById(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.findKeyByName('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.findKeyByName(rsaKeyName).then(fail, err => err) + expect(err).to.exist() + }) + + it('creates the new key name', async () => { + const key = await ks.findKeyByName(renamedRsaKeyName) + expect(key).to.exist() + expect(key).to.have.property('name', renamedRsaKeyName) + }) + + it('does not change the key ID', async () => { + const key = await ks.findKeyByName(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.findKeyByName(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/node.js b/test/keychain/node.js new file mode 100644 index 0000000000..bbb2508960 --- /dev/null +++ b/test/keychain/node.js @@ -0,0 +1,31 @@ +/* eslint-env mocha */ +'use strict' + +const os = require('os') +const path = require('path') +const promisify = require('promisify-es6') +const rimraf = promisify(require('rimraf')) +const FsStore = require('datastore-fs') + +describe('node', () => { + const store1 = path.join(os.tmpdir(), 'test-keystore-1-' + Date.now()) + const store2 = path.join(os.tmpdir(), 'test-keystore-2-' + Date.now()) + const datastore1 = new FsStore(store1) + const datastore2 = new FsStore(store2) + + before(async () => { + await datastore1.open() + await datastore2.open() + }) + + after(async () => { + await datastore1.close() + await datastore2.close() + await rimraf(store1) + await rimraf(store2) + }) + + require('./keychain.spec')(datastore1, datastore2) + require('./cms-interop')(datastore2) + require('./peerid') +}) diff --git a/test/keychain/peerid.js b/test/keychain/peerid.js new file mode 100644 index 0000000000..4360e5388d --- /dev/null +++ b/test/keychain/peerid.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() + }) +})