diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index ef41d4f..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.png binary -* crlf=input diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0bc3b42 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + time: "10:00" + open-pull-requests-limit: 10 + commit-message: + prefix: "deps" + prefix-development: "deps(dev)" diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..d57c2a0 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,8 @@ +name: Automerge +on: [ pull_request ] + +jobs: + automerge: + uses: protocol/.github/.github/workflows/automerge.yml@master + with: + job: 'automerge' diff --git a/.github/workflows/js-test-and-release.yml b/.github/workflows/js-test-and-release.yml new file mode 100644 index 0000000..3cb1621 --- /dev/null +++ b/.github/workflows/js-test-and-release.yml @@ -0,0 +1,143 @@ +name: test & maybe release +on: + push: + branches: + - master # with #262 - ${{{ github.default_branch }}} + pull_request: + +jobs: + + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present lint + - run: npm run --if-present dep-check + + test-node: + needs: check + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + node: [lts/*] + fail-fast: true + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present test:node + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: node + + test-chrome: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present test:chrome + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: chrome + + test-chrome-webworker: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present test:chrome-webworker + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: chrome-webworker + + test-firefox: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present test:firefox + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: firefox + + test-firefox-webworker: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npm run --if-present test:firefox-webworker + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: firefox-webworker + + test-electron-main: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npx xvfb-maybe npm run --if-present test:electron-main + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: electron-main + + test-electron-renderer: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - run: npx xvfb-maybe npm run --if-present test:electron-renderer + - uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 # v3.1.0 + with: + flags: electron-renderer + + release: + needs: [test-node, test-chrome, test-chrome-webworker, test-firefox, test-firefox-webworker, test-electron-main, test-electron-renderer] + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' # with #262 - 'refs/heads/${{{ github.default_branch }}}' + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + - uses: ipfs/aegir/actions/cache-node-modules@master + - uses: ipfs/aegir/actions/docker-login@master + with: + docker-token: ${{ secrets.DOCKER_TOKEN }} + docker-username: ${{ secrets.DOCKER_USERNAME }} + - run: npm run --if-present release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..6f6d895 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,26 @@ +name: Close and mark stale issue + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Oops, seems like we needed more information for this issue, please comment with more details or this issue will be closed in 7 days.' + close-issue-message: 'This issue was closed because it is missing author input.' + stale-issue-label: 'kind/stale' + any-of-labels: 'need/author-input' + exempt-issue-labels: 'need/triage,need/community-input,need/maintainer-input,need/maintainers-input,need/analysis,status/blocked,status/in-progress,status/ready,status/deferred,status/inactive' + days-before-issue-stale: 6 + days-before-issue-close: 7 + enable-statistics: true diff --git a/.gitignore b/.gitignore index 3da57e8..910f633 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,8 @@ -docs -**/node_modules/ -**/*.log -test/repo-tests* -**/bundle.js - -# Logs -logs -*.log - -coverage -.nyc_output - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -build - -# Dependency directory -# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules - -lib +build dist -test/test-data/go-ipfs-repo/LOCK -test/test-data/go-ipfs-repo/LOG -test/test-data/go-ipfs-repo/LOG.old - -# while testing npm5 +.docs +.coverage +node_modules package-lock.json -yarn.lock \ No newline at end of file +yarn.lock diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2061bd3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,42 +0,0 @@ -language: node_js -cache: npm -stages: - - check - - test - - cov - -node_js: - - '10' - - '12' - -os: - - linux - - osx - - windows - -script: npx nyc -s npm run test:node -- --bail -after_success: npx nyc report --reporter=text-lcov > coverage.lcov && npx codecov - -jobs: - include: - - stage: check - script: - - npx aegir dep-check - - npm run lint - - - stage: test - name: chrome - addons: - chrome: stable - script: - - npx aegir test -t browser -t webworker - - - stage: test - name: firefox - addons: - firefox: latest - script: - - npx aegir test -t browser -t webworker -- --browsers FirefoxHeadless - -notifications: - email: false diff --git a/LICENSE b/LICENSE index bbfffbf..20ce483 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,4 @@ -MIT License +This project is dual licensed under MIT and Apache-2.0. -Copyright (c) 2017 libp2p - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..14478a3 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..72dc60d --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 45d7b43..8ce6664 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,37 @@ -⛔️ DEPRECATED: libp2p-keychain is now in [libp2p/src/keychain](https://github.com/libp2p/js-libp2p/tree/master/src/keychain) per [libp2p@0.28.0](https://github.com/libp2p/js-libp2p/releases/tag/v0.28.0). -====== +# @libp2p/keychain -# js-libp2p-keychain +[![libp2p.io](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![Discuss](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg?style=flat-square)](https://discuss.libp2p.io) +[![codecov](https://img.shields.io/codecov/c/github/libp2p/js-libp2p-keychain.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p-keychain) +[![CI](https://img.shields.io/github/actions/workflow/status/libp2p/js-libp2p-keychain/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/libp2p/js-libp2p-keychain/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) -[![](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) +> Key management and cryptographically protected messages -> A secure key chain for libp2p in JavaScript +## Table of contents -## Lead Maintainer +- [Install](#install) + - [Browser ` +``` ## Features @@ -26,52 +41,8 @@ - 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. @@ -107,20 +78,19 @@ const defaultOptions = { ### 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. +The actual physical storage of an encrypted key is left to implementations of [interface-datastore](https://github.com/ipfs/interface-datastore/). A key benefit 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) +## API Docs -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)! +## License -This repository falls under the IPFS [Code of Conduct](https://github.com/ipfs/community/blob/master/code-of-conduct.md). +Licensed under either of -[![](https://cdn.rawgit.com/jbenet/contribute-ipfs-gif/master/img/contribute.gif)](https://github.com/ipfs/community/blob/master/CONTRIBUTING.md) +- Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](LICENSE-MIT) / ) -## License +## Contribution -[MIT](LICENSE) +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/package.json b/package.json index fcba643..066f8e6 100644 --- a/package.json +++ b/package.json @@ -1,76 +1,164 @@ { - "name": "libp2p-keychain", - "version": "0.6.1", + "name": "@libp2p/keychain", + "version": "0.6.2", "description": "Key management and cryptographically protected messages", - "leadMaintainer": "Vasco Santos ", - "main": "src/index.js", - "scripts": { - "lint": "aegir lint", - "build": "aegir build", - "coverage": "nyc --reporter=text --reporter=lcov npm run test:node", - "test": "aegir test -t node -t browser", - "test:node": "aegir test -t node", - "test:browser": "aegir test -t browser", - "release": "aegir release", - "release-minor": "aegir release --type minor", - "release-major": "aegir release --type major" - }, - "pre-push": [ - "lint" - ], - "engines": { - "node": ">=10.0.0", - "npm": ">=3.0.0" - }, + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p-keychain#readme", "repository": { "type": "git", "url": "git+https://github.com/libp2p/js-libp2p-keychain.git" }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p-keychain/issues" + }, "keywords": [ "IPFS", - "libp2p", - "keys", + "crypto", "encryption", - "secure", - "crypto" + "keys", + "libp2p", + "secure" ], - "license": "MIT", - "bugs": { - "url": "https://github.com/libp2p/js-libp2p-keychain/issues" + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + }, + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "sourceType": "module" + } + }, + "release": { + "branches": [ + "master" + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "breaking": true, + "release": "major" + }, + { + "revert": true, + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "docs", + "release": "patch" + }, + { + "type": "test", + "release": "patch" + }, + { + "type": "deps", + "release": "patch" + }, + { + "scope": "no-release", + "release": false + } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "chore", + "section": "Trivial Changes" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "deps", + "section": "Dependencies" + }, + { + "type": "test", + "section": "Tests" + } + ] + } + } + ], + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + "@semantic-release/git" + ] + }, + "scripts": { + "clean": "aegir clean", + "lint": "aegir lint", + "dep-check": "aegir dep-check", + "build": "aegir build", + "test": "aegir test", + "test:chrome": "aegir test -t browser --cov", + "test:chrome-webworker": "aegir test -t webworker", + "test:firefox": "aegir test -t browser -- --browser firefox", + "test:firefox-webworker": "aegir test -t webworker -- --browser firefox", + "test:node": "aegir test -t node --cov", + "test:electron-main": "aegir test -t electron-main", + "release": "aegir release", + "docs": "aegir docs" }, - "homepage": "https://github.com/libp2p/js-libp2p-keychain#readme", "dependencies": { - "err-code": "^2.0.0", - "interface-datastore": "^1.0.2", - "libp2p-crypto": "^0.17.1", - "merge-options": "^2.0.0", - "node-forge": "^0.9.1", - "sanitize-filename": "^1.6.1" + "@libp2p/crypto": "^1.0.11", + "@libp2p/interface-keychain": "^2.0.3", + "@libp2p/interface-peer-id": "^2.0.1", + "@libp2p/logger": "^2.0.5", + "@libp2p/peer-id": "^2.0.1", + "err-code": "^3.0.1", + "interface-datastore": "^7.0.3", + "merge-options": "^3.0.4", + "sanitize-filename": "^1.6.3", + "uint8arrays": "^4.0.3" }, "devDependencies": { - "aegir": "^22.0.0", - "chai": "^4.2.0", - "chai-string": "^1.5.0", - "datastore-fs": "^1.0.0", - "datastore-level": "^1.0.0", - "dirty-chai": "^2.0.1", - "level": "^6.0.0", - "multihashes": "^0.4.15", - "peer-id": "^0.13.5", - "promisify-es6": "^1.0.3", - "rimraf": "^3.0.0" - }, - "contributors": [ - "Vasco Santos ", - "Richard Schneider ", - "David Dias ", - "Alan Shaw ", - "Alex Potsides ", - "Alberto Elias ", - "Masahiro Saito ", - "Maciej Krüger ", - "Jacob Heun ", - "Hugo Dias ", - "Victor Bjelkholm " - ] + "@libp2p/peer-id-factory": "^2.0.1", + "aegir": "^38.1.0", + "datastore-core": "^8.0.4", + "multiformats": "^11.0.1" + } } diff --git a/src/cms.js b/src/cms.js deleted file mode 100644 index 9bec4b9..0000000 --- a/src/cms.js +++ /dev/null @@ -1,122 +0,0 @@ -'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/errors.ts b/src/errors.ts new file mode 100644 index 0000000..791d87f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,18 @@ + +export enum codes { + ERR_INVALID_PARAMETERS = 'ERR_INVALID_PARAMETERS', + ERR_INVALID_KEY_NAME = 'ERR_INVALID_KEY_NAME', + ERR_INVALID_KEY_TYPE = 'ERR_INVALID_KEY_TYPE', + ERR_KEY_ALREADY_EXISTS = 'ERR_KEY_ALREADY_EXISTS', + ERR_INVALID_KEY_SIZE = 'ERR_INVALID_KEY_SIZE', + ERR_KEY_NOT_FOUND = 'ERR_KEY_NOT_FOUND', + ERR_OLD_KEY_NAME_INVALID = 'ERR_OLD_KEY_NAME_INVALID', + ERR_NEW_KEY_NAME_INVALID = 'ERR_NEW_KEY_NAME_INVALID', + ERR_PASSWORD_REQUIRED = 'ERR_PASSWORD_REQUIRED', + ERR_PEM_REQUIRED = 'ERR_PEM_REQUIRED', + ERR_CANNOT_READ_KEY = 'ERR_CANNOT_READ_KEY', + ERR_MISSING_PRIVATE_KEY = 'ERR_MISSING_PRIVATE_KEY', + ERR_INVALID_OLD_PASS_TYPE = 'ERR_INVALID_OLD_PASS_TYPE', + ERR_INVALID_NEW_PASS_TYPE = 'ERR_INVALID_NEW_PASS_TYPE', + ERR_INVALID_PASS_LENGTH = 'ERR_INVALID_PASS_LENGTH' +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 2704d62..0000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict' - -module.exports = require('./keychain') diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e21269a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,577 @@ +/* eslint max-nested-callbacks: ["error", 5] */ + +import { logger } from '@libp2p/logger' +import sanitize from 'sanitize-filename' +import mergeOptions from 'merge-options' +import { Key } from 'interface-datastore/key' +import errCode from 'err-code' +import { codes } from './errors.js' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { generateKeyPair, importKey, unmarshalPrivateKey } from '@libp2p/crypto/keys' +import type { PeerId } from '@libp2p/interface-peer-id' +import { pbkdf2, randomBytes } from '@libp2p/crypto' +import type { Datastore } from 'interface-datastore' +import { peerIdFromKeys } from '@libp2p/peer-id' +import type { KeyChain, KeyInfo, KeyType } from '@libp2p/interface-keychain' + +const log = logger('libp2p:keychain') + +export interface DEKConfig { + hash: string + salt: string + iterationCount: number + keyLength: number +} + +export interface KeyChainInit { + pass?: string + dek?: DEKConfig +} + +const keyPrefix = '/pkcs8/' +const infoPrefix = '/info/' +const privates = new WeakMap() + +// 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: string): boolean { + if (name == null) { + return false + } + if (typeof name !== 'string') { + return false + } + return name === sanitize(name.trim()) && name.length > 0 +} + +/** + * 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. + */ +async function randomDelay (): Promise { + const min = 200 + const max = 1000 + const delay = Math.random() * (max - min) + min + + await new Promise(resolve => setTimeout(resolve, delay)) +} + +/** + * Converts a key name into a datastore name + */ +function DsName (name: string): Key { + return new Key(keyPrefix + name) +} + +/** + * Converts a key name into a datastore info name + */ +function DsInfoName (name: string): Key { + return new Key(infoPrefix + name) +} + +export interface KeyChainComponents { + datastore: Datastore +} + +/** + * 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 + * + */ +export class DefaultKeyChain implements KeyChain { + private readonly components: KeyChainComponents + private readonly init: KeyChainInit + + /** + * Creates a new instance of a key chain + */ + constructor (components: KeyChainComponents, init: KeyChainInit) { + this.components = components + this.init = mergeOptions(defaultOptions, init) + + // Enforce NIST SP 800-132 + if (this.init.pass != null && this.init.pass?.length < 20) { + throw new Error('pass must be least 20 characters') + } + if (this.init.dek?.keyLength != null && this.init.dek.keyLength < NIST.minKeyLength) { + throw new Error(`dek.keyLength must be least ${NIST.minKeyLength} bytes`) + } + if (this.init.dek?.salt?.length != null && this.init.dek.salt.length < NIST.minSaltLength) { + throw new Error(`dek.saltLength must be least ${NIST.minSaltLength} bytes`) + } + if (this.init.dek?.iterationCount != null && this.init.dek.iterationCount < NIST.minIterationCount) { + throw new Error(`dek.iterationCount must be least ${NIST.minIterationCount}`) + } + + const dek = this.init.pass != null && this.init.dek?.salt != null + ? pbkdf2( + this.init.pass, + this.init.dek?.salt, + this.init.dek?.iterationCount, + this.init.dek?.keyLength, + this.init.dek?.hash) + : '' + + privates.set(this, { dek }) + } + + /** + * Generates the options for a keychain. A random salt is produced. + * + * @returns {object} + */ + static generateOptions (): KeyChainInit { + const options = Object.assign({}, defaultOptions) + const saltLength = Math.ceil(NIST.minSaltLength / 3) * 3 // no base64 padding + options.dek.salt = uint8ArrayToString(randomBytes(saltLength), 'base64') + return options + } + + /** + * Gets an object that can encrypt/decrypt protected data. + * The default options for a keychain. + * + * @returns {object} + */ + static get options (): typeof defaultOptions { + 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 {number} [size = 2048] - The key size in bits. Used for rsa keys only + */ + async createKey (name: string, type: KeyType, size = 2048): Promise { + if (!validateKeyName(name) || name === 'self') { + await randomDelay() + throw errCode(new Error('Invalid key name'), codes.ERR_INVALID_KEY_NAME) + } + + if (typeof type !== 'string') { + await randomDelay() + throw errCode(new Error('Invalid key type'), codes.ERR_INVALID_KEY_TYPE) + } + + const dsname = DsName(name) + const exists = await this.components.datastore.has(dsname) + if (exists) { + await randomDelay() + throw errCode(new Error('Key name already exists'), codes.ERR_KEY_ALREADY_EXISTS) + } + + switch (type.toLowerCase()) { + case 'rsa': + if (!Number.isSafeInteger(size) || size < 2048) { + await randomDelay() + throw errCode(new Error('Invalid RSA key size'), codes.ERR_INVALID_KEY_SIZE) + } + break + default: + break + } + + let keyInfo + try { + const keypair = await generateKeyPair(type, size) + const kid = await keypair.id() + const cached = privates.get(this) + + if (cached == null) { + throw errCode(new Error('dek missing'), codes.ERR_INVALID_PARAMETERS) + } + + const dek = cached.dek + const pem = await keypair.export(dek) + keyInfo = { + name, + id: kid + } + const batch = this.components.datastore.batch() + batch.put(dsname, uint8ArrayFromString(pem)) + batch.put(DsInfoName(name), uint8ArrayFromString(JSON.stringify(keyInfo))) + + await batch.commit() + } catch (err: any) { + await randomDelay() + throw err + } + + return keyInfo + } + + /** + * List all the keys. + * + * @returns {Promise} + */ + async listKeys (): Promise { + const query = { + prefix: infoPrefix + } + + const info = [] + for await (const value of this.components.datastore.query(query)) { + info.push(JSON.parse(uint8ArrayToString(value.value))) + } + + return info + } + + /** + * Find a key by it's id + */ + async findKeyById (id: string): Promise { + try { + const keys = await this.listKeys() + const key = keys.find((k) => k.id === id) + + if (key == null) { + throw errCode(new Error(`Key with id '${id}' does not exist.`), codes.ERR_KEY_NOT_FOUND) + } + + return key + } catch (err: any) { + await randomDelay() + throw err + } + } + + /** + * Find a key by it's name. + * + * @param {string} name - The local key name. + * @returns {Promise} + */ + async findKeyByName (name: string): Promise { + if (!validateKeyName(name)) { + await randomDelay() + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + + const dsname = DsInfoName(name) + try { + const res = await this.components.datastore.get(dsname) + return JSON.parse(uint8ArrayToString(res)) + } catch (err: any) { + await randomDelay() + log.error(err) + throw errCode(new Error(`Key '${name}' does not exist.`), codes.ERR_KEY_NOT_FOUND) + } + } + + /** + * Remove an existing key. + * + * @param {string} name - The local key name; must already exist. + * @returns {Promise} + */ + async removeKey (name: string): Promise { + if (!validateKeyName(name) || name === 'self') { + await randomDelay() + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + const dsname = DsName(name) + const keyInfo = await this.findKeyByName(name) + const batch = this.components.datastore.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 {Promise} + */ + async renameKey (oldName: string, newName: string): Promise { + if (!validateKeyName(oldName) || oldName === 'self') { + await randomDelay() + throw errCode(new Error(`Invalid old key name '${oldName}'`), codes.ERR_OLD_KEY_NAME_INVALID) + } + if (!validateKeyName(newName) || newName === 'self') { + await randomDelay() + throw errCode(new Error(`Invalid new key name '${newName}'`), codes.ERR_NEW_KEY_NAME_INVALID) + } + const oldDsname = DsName(oldName) + const newDsname = DsName(newName) + const oldInfoName = DsInfoName(oldName) + const newInfoName = DsInfoName(newName) + + const exists = await this.components.datastore.has(newDsname) + if (exists) { + await randomDelay() + throw errCode(new Error(`Key '${newName}' already exists`), codes.ERR_KEY_ALREADY_EXISTS) + } + + try { + const pem = await this.components.datastore.get(oldDsname) + const res = await this.components.datastore.get(oldInfoName) + + const keyInfo = JSON.parse(uint8ArrayToString(res)) + keyInfo.name = newName + const batch = this.components.datastore.batch() + batch.put(newDsname, pem) + batch.put(newInfoName, uint8ArrayFromString(JSON.stringify(keyInfo))) + batch.delete(oldDsname) + batch.delete(oldInfoName) + await batch.commit() + return keyInfo + } catch (err: any) { + await randomDelay() + throw err + } + } + + /** + * Export an existing key as a PEM encrypted PKCS #8 string + */ + async exportKey (name: string, password: string): Promise { + if (!validateKeyName(name)) { + await randomDelay() + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + if (password == null) { + await randomDelay() + throw errCode(new Error('Password is required'), codes.ERR_PASSWORD_REQUIRED) + } + + const dsname = DsName(name) + try { + const res = await this.components.datastore.get(dsname) + const pem = uint8ArrayToString(res) + const cached = privates.get(this) + + if (cached == null) { + throw errCode(new Error('dek missing'), codes.ERR_INVALID_PARAMETERS) + } + + const dek = cached.dek + const privateKey = await importKey(pem, dek) + return await privateKey.export(password) + } catch (err: any) { + await randomDelay() + throw err + } + } + + /** + * Export an existing key as a PeerId + */ + async exportPeerId (name: string): Promise { + const password = 'temporary-password' + const pem = await this.exportKey(name, password) + const privateKey = await importKey(pem, password) + + return await peerIdFromKeys(privateKey.public.bytes, privateKey.bytes) + } + + /** + * 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 {Promise} + */ + async importKey (name: string, pem: string, password: string): Promise { + if (!validateKeyName(name) || name === 'self') { + await randomDelay() + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + if (pem == null) { + await randomDelay() + throw errCode(new Error('PEM encoded key is required'), codes.ERR_PEM_REQUIRED) + } + const dsname = DsName(name) + const exists = await this.components.datastore.has(dsname) + if (exists) { + await randomDelay() + throw errCode(new Error(`Key '${name}' already exists`), codes.ERR_KEY_ALREADY_EXISTS) + } + + let privateKey + try { + privateKey = await importKey(pem, password) + } catch (err: any) { + await randomDelay() + throw errCode(new Error('Cannot read the key, most likely the password is wrong'), codes.ERR_CANNOT_READ_KEY) + } + + let kid + try { + kid = await privateKey.id() + const cached = privates.get(this) + + if (cached == null) { + throw errCode(new Error('dek missing'), codes.ERR_INVALID_PARAMETERS) + } + + const dek = cached.dek + pem = await privateKey.export(dek) + } catch (err: any) { + await randomDelay() + throw err + } + + const keyInfo = { + name, + id: kid + } + const batch = this.components.datastore.batch() + batch.put(dsname, uint8ArrayFromString(pem)) + batch.put(DsInfoName(name), uint8ArrayFromString(JSON.stringify(keyInfo))) + await batch.commit() + + return keyInfo + } + + /** + * Import a peer key + */ + async importPeer (name: string, peer: PeerId): Promise { + try { + if (!validateKeyName(name)) { + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + if (peer == null) { + throw errCode(new Error('PeerId is required'), codes.ERR_MISSING_PRIVATE_KEY) + } + if (peer.privateKey == null) { + throw errCode(new Error('PeerId.privKey is required'), codes.ERR_MISSING_PRIVATE_KEY) + } + + const privateKey = await unmarshalPrivateKey(peer.privateKey) + + const dsname = DsName(name) + const exists = await this.components.datastore.has(dsname) + if (exists) { + await randomDelay() + throw errCode(new Error(`Key '${name}' already exists`), codes.ERR_KEY_ALREADY_EXISTS) + } + + const cached = privates.get(this) + + if (cached == null) { + throw errCode(new Error('dek missing'), codes.ERR_INVALID_PARAMETERS) + } + + const dek = cached.dek + const pem = await privateKey.export(dek) + const keyInfo: KeyInfo = { + name, + id: peer.toString() + } + const batch = this.components.datastore.batch() + batch.put(dsname, uint8ArrayFromString(pem)) + batch.put(DsInfoName(name), uint8ArrayFromString(JSON.stringify(keyInfo))) + await batch.commit() + return keyInfo + } catch (err: any) { + await randomDelay() + throw err + } + } + + /** + * Gets the private key as PEM encoded PKCS #8 string + */ + async getPrivateKey (name: string): Promise { + if (!validateKeyName(name)) { + await randomDelay() + throw errCode(new Error(`Invalid key name '${name}'`), codes.ERR_INVALID_KEY_NAME) + } + + try { + const dsname = DsName(name) + const res = await this.components.datastore.get(dsname) + return uint8ArrayToString(res) + } catch (err: any) { + await randomDelay() + log.error(err) + throw errCode(new Error(`Key '${name}' does not exist.`), codes.ERR_KEY_NOT_FOUND) + } + } + + /** + * Rotate keychain password and re-encrypt all associated keys + */ + async rotateKeychainPass (oldPass: string, newPass: string): Promise { + if (typeof oldPass !== 'string') { + await randomDelay() + throw errCode(new Error(`Invalid old pass type '${typeof oldPass}'`), codes.ERR_INVALID_OLD_PASS_TYPE) + } + if (typeof newPass !== 'string') { + await randomDelay() + throw errCode(new Error(`Invalid new pass type '${typeof newPass}'`), codes.ERR_INVALID_NEW_PASS_TYPE) + } + if (newPass.length < 20) { + await randomDelay() + throw errCode(new Error(`Invalid pass length ${newPass.length}`), codes.ERR_INVALID_PASS_LENGTH) + } + log('recreating keychain') + const cached = privates.get(this) + + if (cached == null) { + throw errCode(new Error('dek missing'), codes.ERR_INVALID_PARAMETERS) + } + + const oldDek = cached.dek + this.init.pass = newPass + const newDek = newPass != null && this.init.dek?.salt != null + ? pbkdf2( + newPass, + this.init.dek.salt, + this.init.dek?.iterationCount, + this.init.dek?.keyLength, + this.init.dek?.hash) + : '' + privates.set(this, { dek: newDek }) + const keys = await this.listKeys() + for (const key of keys) { + const res = await this.components.datastore.get(DsName(key.name)) + const pem = uint8ArrayToString(res) + const privateKey = await importKey(pem, oldDek) + const password = newDek.toString() + const keyAsPEM = await privateKey.export(password) + + // Update stored key + const batch = this.components.datastore.batch() + const keyInfo = { + name: key.name, + id: key.id + } + batch.put(DsName(key.name), uint8ArrayFromString(keyAsPEM)) + batch.put(DsInfoName(key.name), uint8ArrayFromString(JSON.stringify(keyInfo))) + await batch.commit() + } + log('keychain reconstructed') + } +} diff --git a/src/keychain.js b/src/keychain.js deleted file mode 100644 index aae7897..0000000 --- a/src/keychain.js +++ /dev/null @@ -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 diff --git a/src/util.js b/src/util.js deleted file mode 100644 index 50ce417..0000000 --- a/src/util.js +++ /dev/null @@ -1,89 +0,0 @@ -'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/src/util.ts b/src/util.ts new file mode 100644 index 0000000..e708fd1 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,16 @@ +/** + * 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 + */ +export async function findAsync (array: T[], asyncCompare: (val: T) => Promise): Promise { + const promises = array.map(asyncCompare) + const results = await Promise.all(promises) + const index = results.findIndex(result => result) + return array[index] +} diff --git a/test/browser.js b/test/browser.js deleted file mode 100644 index 02222fb..0000000 --- a/test/browser.js +++ /dev/null @@ -1,27 +0,0 @@ -/* 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/cms-interop.js b/test/cms-interop.js deleted file mode 100644 index 06eb631..0000000 --- a/test/cms-interop.js +++ /dev/null @@ -1,66 +0,0 @@ -/* 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('..') - -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.spec.js b/test/keychain.spec.js deleted file mode 100644 index c455f2d..0000000 --- a/test/keychain.spec.js +++ /dev/null @@ -1,383 +0,0 @@ -/* 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('../') -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.spec.ts b/test/keychain.spec.ts new file mode 100644 index 0000000..9353b3b --- /dev/null +++ b/test/keychain.spec.ts @@ -0,0 +1,559 @@ +/* eslint max-nested-callbacks: ["error", 8] */ +/* eslint-env mocha */ + +import { expect } from 'aegir/chai' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { Key } from 'interface-datastore/key' +import { MemoryDatastore } from 'datastore-core/memory' +import { DefaultKeyChain, KeyChainInit } from '../src/index.js' +import { pbkdf2 } from '@libp2p/crypto' +import type { Datastore } from 'interface-datastore' +import type { PeerId } from '@libp2p/interface-peer-id' +import { createFromPrivKey } from '@libp2p/peer-id-factory' +import { unmarshalPrivateKey } from '@libp2p/crypto/keys' +import type { KeyChain, KeyInfo } from '@libp2p/interface-keychain' + +describe('keychain', () => { + const passPhrase = 'this is not a secure phrase' + const rsaKeyName = 'tajné jméno' + const renamedRsaKeyName = 'ชื่อลับ' + let rsaKeyInfo: KeyInfo + let ks: DefaultKeyChain + let datastore2: Datastore + + before(async () => { + datastore2 = new MemoryDatastore() + + ks = new DefaultKeyChain({ + datastore: datastore2 + }, { pass: passPhrase }) + + await datastore2.open() + }) + + after(async () => { + await datastore2.close() + }) + + it('can start without a password', async () => { + await expect(async function () { + return new DefaultKeyChain({ + datastore: datastore2 + }, {}) + }()).to.eventually.be.ok() + }) + + it('needs a NIST SP 800-132 non-weak pass phrase', async () => { + await expect(async function () { + return new DefaultKeyChain({ + datastore: datastore2 + }, { pass: '< 20 character' }) + }()).to.eventually.be.rejected() + }) + + it('has default options', () => { + expect(DefaultKeyChain.options).to.exist() + }) + + it('supports supported hashing alorithms', async () => { + const ok = new DefaultKeyChain({ + datastore: datastore2 + }, { pass: passPhrase, dek: { hash: 'sha2-256', salt: 'salt-salt-salt-salt', iterationCount: 1000, keyLength: 14 } }) + expect(ok).to.exist() + }) + + it('does not support unsupported hashing alorithms', async () => { + await expect(async function () { + return new DefaultKeyChain({ + datastore: datastore2 + }, { pass: passPhrase, dek: { hash: 'my-hash', salt: 'salt-salt-salt-salt', iterationCount: 1000, keyLength: 14 } }) + }()).to.eventually.be.rejected() + }) + + it('can list keys without a password', async () => { + const keychain = new DefaultKeyChain({ + datastore: datastore2 + }, {}) + + expect(await keychain.listKeys()).to.have.lengthOf(0) + }) + + it('can find a key without a password', async () => { + const keychain = new DefaultKeyChain({ + datastore: datastore2 + }, {}) + const keychainWithPassword = new DefaultKeyChain({ + datastore: datastore2 + }, { pass: `hello-${Date.now()}-${Date.now()}` }) + const name = `key-${Math.random()}` + + const { id } = await keychainWithPassword.createKey(name, 'Ed25519') + + await expect(keychain.findKeyById(id)).to.eventually.be.ok() + }) + + it('can remove a key without a password', async () => { + const keychainWithoutPassword = new DefaultKeyChain({ + datastore: datastore2 + }, {}) + const keychainWithPassword = new DefaultKeyChain({ + datastore: datastore2 + }, { pass: `hello-${Date.now()}-${Date.now()}` }) + const name = `key-${Math.random()}` + + expect(await keychainWithPassword.createKey(name, 'Ed25519')).to.have.property('name', name) + expect(await keychainWithoutPassword.findKeyByName(name)).to.have.property('name', name) + await keychainWithoutPassword.removeKey(name) + await expect(keychainWithoutPassword.findKeyByName(name)).to.be.rejectedWith(/does not exist/) + }) + + it('requires a name to create a password', async () => { + const keychain = new DefaultKeyChain({ + datastore: datastore2 + }, {}) + + // @ts-expect-error invalid parameters + await expect(keychain.createKey(undefined, 'derp')).to.eventually.be.rejected() + }) + + it('can generate options', async () => { + const options = DefaultKeyChain.generateOptions() + options.pass = passPhrase + const chain = new DefaultKeyChain({ + datastore: 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').catch(err => err), + ks.removeKey('').catch(err => err), + ks.removeKey(' ').catch(err => err), + // @ts-expect-error invalid parameters + ks.removeKey(null).catch(err => err), + // @ts-expect-error invalid parameters + ks.removeKey(undefined).catch(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 () => { + // @ts-expect-error invalid parameters + await expect(ks.getPrivateKey(undefined)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('throws if a private key cant be found', async () => { + await expect(ks.getPrivateKey('not real')).to.eventually.be.rejected.with.property('code', 'ERR_KEY_NOT_FOUND') + }) + + it('does not overwrite existing key', async () => { + await expect(ks.createKey(rsaKeyName, 'RSA', 2048)).to.eventually.be.rejected.with.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot create the "self" key', async () => { + await expect(ks.createKey('self', 'RSA', 2048)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('should validate name is string', async () => { + // @ts-expect-error invalid parameters + await expect(ks.createKey(5, 'rsa', 2048)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('should validate type is string', async () => { + // @ts-expect-error invalid parameters + await expect(ks.createKey(`TEST-${Date.now()}`, null, 2048)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_TYPE') + }) + + it('should validate size is integer', async () => { + // @ts-expect-error invalid parameters + await expect(ks.createKey(`TEST-${Date.now()}`, 'RSA', 'string')).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_SIZE') + }) + + describe('implements NIST SP 800-131A', () => { + it('disallows RSA length < 2048', async () => { + await expect(ks.createKey('bad-nist-rsa', 'RSA', 1024)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_SIZE') + }) + }) + }) + + describe('Ed25519 keys', () => { + const keyName = 'my custom key' + it('can be an Ed25519 key', async () => { + const keyInfo = await ks.createKey(keyName, 'Ed25519') + expect(keyInfo).to.exist() + expect(keyInfo).to.have.property('name', keyName) + expect(keyInfo).to.have.property('id') + }) + + it('does not overwrite existing key', async () => { + await expect(ks.createKey(keyName, 'Ed25519')).to.eventually.be.rejected.with.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('can export/import a key', async () => { + const keyName = 'a new key' + const password = 'my sneaky password' + const keyInfo = await ks.createKey(keyName, 'Ed25519') + const exportedKey = await ks.exportKey(keyName, password) + // remove it so we can import it + await ks.removeKey(keyName) + const importedKey = await ks.importKey(keyName, exportedKey, password) + expect(importedKey.id).to.eql(keyInfo.id) + }) + + it('cannot create the "self" key', async () => { + await expect(ks.createKey('self', 'Ed25519')).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + }) + + 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('exported key', () => { + let pemKey: string + + it('requires the password', async () => { + // @ts-expect-error invalid parameters + await expect(ks.exportKey(rsaKeyName)).to.eventually.be.rejected.with.property('code', 'ERR_PASSWORD_REQUIRED') + }) + + it('requires the key name', async () => { + // @ts-expect-error invalid parameters + await expect(ks.exportKey(undefined, 'password')).to.eventually.be.rejected.with.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 () => { + // @ts-expect-error invalid parameters + await expect(ks.importKey('imported-key', undefined, 'password')).to.eventually.be.rejected.with.property('code', 'ERR_PEM_REQUIRED') + }) + + it('cannot be imported as an existing key name', async () => { + await expect(ks.importKey(rsaKeyName, pemKey, 'password')).to.eventually.be.rejected.with.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot be imported with the wrong password', async () => { + await expect(ks.importKey('a-new-name-for-import', pemKey, 'not the password')).to.eventually.be.rejected.with.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: PeerId + + before(async function () { + const encoded = uint8ArrayFromString(alicePrivKey, 'base64pad') + const privateKey = await unmarshalPrivateKey(encoded) + alice = await createFromPrivKey(privateKey) + }) + + 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.toString()) + }) + + it('private key can be exported', async () => { + const alice2 = await ks.exportPeerId('alice') + + expect(alice.equals(alice2)).to.be.true() + expect(alice2).to.have.property('privateKey').that.is.ok() + expect(alice2).to.have.property('publicKey').that.is.ok() + }) + + it('private key import requires a valid name', async () => { + // @ts-expect-error invalid parameters + await expect(ks.importPeer(undefined, alice)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('private key import requires the peer', async () => { + // @ts-expect-error invalid parameters + await expect(ks.importPeer('alice')).to.eventually.be.rejected.with.property('code', 'ERR_MISSING_PRIVATE_KEY') + }) + + it('key id exists', async () => { + const key = await ks.findKeyById(alice.toString()) + expect(key).to.exist() + expect(key).to.have.property('name', 'alice') + expect(key).to.have.property('id', alice.toString()) + }) + + 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.toString()) + }) + + it('can create Ed25519 peer id', async () => { + const name = 'ed-key' + await ks.createKey(name, 'Ed25519') + const peer = await ks.exportPeerId(name) + + expect(peer).to.have.property('type', 'Ed25519') + expect(peer).to.have.property('privateKey').that.is.ok() + expect(peer).to.have.property('publicKey').that.is.ok() + }) + + it('can create RSA peer id', async () => { + const name = 'rsa-key' + await ks.createKey(name, 'RSA', 2048) + const peer = await ks.exportPeerId(name) + + expect(peer).to.have.property('type', 'RSA') + expect(peer).to.have.property('privateKey').that.is.ok() + expect(peer).to.have.property('publicKey').that.is.ok() + }) + + it('can create secp256k1 peer id', async () => { + const name = 'secp256k1-key' + await ks.createKey(name, 'secp256k1') + const peer = await ks.exportPeerId(name) + + expect(peer).to.have.property('type', 'secp256k1') + expect(peer).to.have.property('privateKey').that.is.ok() + expect(peer).to.have.property('publicKey').that.is.ok() + }) + }) + + describe('rename', () => { + it('requires an existing key name', async () => { + await expect(ks.renameKey('not-there', renamedRsaKeyName)).to.eventually.be.rejected.with.property('code', 'ERR_NOT_FOUND') + }) + + it('requires a valid new key name', async () => { + await expect(ks.renameKey(rsaKeyName, '..\not-valid')).to.eventually.be.rejected.with.property('code', 'ERR_NEW_KEY_NAME_INVALID') + }) + + it('does not overwrite existing key', async () => { + await expect(ks.renameKey(rsaKeyName, rsaKeyName)).to.eventually.be.rejected.with.property('code', 'ERR_KEY_ALREADY_EXISTS') + }) + + it('cannot create the "self" key', async () => { + await expect(ks.renameKey(rsaKeyName, 'self')).to.eventually.be.rejected.with.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 + await expect(ks.findKeyByName(rsaKeyName)).to.eventually.be.rejected() + }) + + 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 () => { + // @ts-expect-error invalid parameters + await expect(ks.findKeyByName(undefined)).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + }) + + describe('key removal', () => { + it('cannot remove the "self" key', async () => { + await expect(ks.removeKey('self')).to.eventually.be.rejected.with.property('code', 'ERR_INVALID_KEY_NAME') + }) + + it('cannot remove an unknown key', async () => { + await expect(ks.removeKey('not-there')).to.eventually.be.rejected.with.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) + }) + }) + + describe('rotate keychain passphrase', () => { + let oldPass: string + let kc: KeyChain + let options: KeyChainInit + let ds: Datastore + before(async () => { + ds = new MemoryDatastore() + oldPass = `hello-${Date.now()}-${Date.now()}` + options = { + pass: oldPass, + dek: { + salt: '3Nd/Ya4ENB3bcByNKptb4IR', + iterationCount: 10000, + keyLength: 64, + hash: 'sha2-512' + } + } + kc = new DefaultKeyChain({ + datastore: ds + }, options) + await ds.open() + }) + + it('should validate newPass is a string', async () => { + // @ts-expect-error invalid parameters + await expect(kc.rotateKeychainPass(oldPass, 1234567890)).to.eventually.be.rejected() + }) + + it('should validate oldPass is a string', async () => { + // @ts-expect-error invalid parameters + await expect(kc.rotateKeychainPass(1234, 'newInsecurePassword1')).to.eventually.be.rejected() + }) + + it('should validate newPass is at least 20 characters', async () => { + try { + await kc.rotateKeychainPass(oldPass, 'not20Chars') + } catch (err: any) { + expect(err).to.exist() + } + }) + + it('can rotate keychain passphrase', async () => { + await kc.createKey('keyCreatedWithOldPassword', 'RSA', 2048) + await kc.rotateKeychainPass(oldPass, 'newInsecurePassphrase') + + // Get Key PEM from datastore + const dsname = new Key('/pkcs8/' + 'keyCreatedWithOldPassword') + const res = await ds.get(dsname) + const pem = uint8ArrayToString(res) + + const oldDek = options.pass != null + ? pbkdf2( + options.pass, + options.dek?.salt ?? 'salt', + options.dek?.iterationCount ?? 0, + options.dek?.keyLength ?? 0, + options.dek?.hash ?? 'sha2-256' + ) + : '' + + const newDek = pbkdf2( + 'newInsecurePassphrase', + options.dek?.salt ?? 'salt', + options.dek?.iterationCount ?? 0, + options.dek?.keyLength ?? 0, + options.dek?.hash ?? 'sha2-256' + ) + + // Dek with old password should not work: + await expect(kc.importKey('keyWhosePassChanged', pem, oldDek)) + .to.eventually.be.rejected() + // Dek with new password should work: + await expect(kc.importKey('keyWhosePasswordChanged', pem, newDek)) + .to.eventually.have.property('name', 'keyWhosePasswordChanged') + }).timeout(10000) + }) +}) + +describe('libp2p.keychain', () => { + it('needs a passphrase to be used, otherwise throws an error', async () => { + expect(() => { + return new DefaultKeyChain({ + datastore: new MemoryDatastore() + }, { + pass: '' + }) + }).to.throw() + }) + + it('can be used when a passphrase is provided', async () => { + const keychain = new DefaultKeyChain({ + datastore: new MemoryDatastore() + }, { + pass: '12345678901234567890' + }) + + const kInfo = await keychain.createKey('keyName', 'Ed25519') + expect(kInfo).to.exist() + }) + + it('can reload keys', async () => { + const datastore = new MemoryDatastore() + const keychain = new DefaultKeyChain({ + datastore + }, { + pass: '12345678901234567890' + }) + + const kInfo = await keychain.createKey('keyName', 'Ed25519') + expect(kInfo).to.exist() + + const keychain2 = new DefaultKeyChain({ + datastore + }, { + pass: '12345678901234567890' + }) + + const key = await keychain2.findKeyByName('keyName') + + expect(key).to.exist() + }) +}) diff --git a/test/node.js b/test/node.js deleted file mode 100644 index bbb2508..0000000 --- a/test/node.js +++ /dev/null @@ -1,31 +0,0 @@ -/* 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/peerid.js b/test/peerid.spec.ts similarity index 67% rename from test/peerid.js rename to test/peerid.spec.ts index 4360e53..bd7c39e 100644 --- a/test/peerid.js +++ b/test/peerid.spec.ts @@ -1,15 +1,11 @@ /* 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') +import { expect } from 'aegir/chai' +import { base58btc } from 'multiformats/bases/base58' +import { supportedKeys, unmarshalPrivateKey, unmarshalPublicKey } from '@libp2p/crypto/keys' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import type { PeerId } from '@libp2p/interface-peer-id' +import { createFromPrivKey } from '@libp2p/peer-id-factory' const sample = { id: '122019318b6e5e0cf93a2314bf01269a2cc23cd3dcd452d742cdb9379d8646f6e4a9', @@ -18,32 +14,39 @@ const sample = { } describe('peer ID', () => { - let peer - let publicKeyDer // a buffer + let peer: PeerId + let publicKeyDer: Uint8Array // a buffer before(async () => { - const encoded = Buffer.from(sample.privKey, 'base64') - peer = await PeerId.createFromPrivKey(encoded) + const encoded = uint8ArrayFromString(sample.privKey, 'base64pad') + peer = await createFromPrivKey(await unmarshalPrivateKey(encoded)) }) it('decoded public key', async () => { + if (peer.publicKey == null) { + throw new Error('PublicKey missing from PeerId') + } + + if (peer.privateKey == null) { + throw new Error('PrivateKey missing from PeerId') + } + // get protobuf version of the public key - const publicKeyProtobuf = peer.marshalPubKey() - const publicKey = crypto.keys.unmarshalPublicKey(publicKeyProtobuf) + const publicKeyProtobuf = peer.publicKey + const publicKey = unmarshalPublicKey(publicKeyProtobuf) publicKeyDer = publicKey.marshal() // get protobuf version of the private key - const privateKeyProtobuf = peer.marshalPrivKey() - const key = await crypto.keys.unmarshalPrivateKey(privateKeyProtobuf) + const privateKeyProtobuf = peer.privateKey + const key = await 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 rsa = supportedKeys.rsa.unmarshalRsaPublicKey(publicKeyDer) const keyId = await rsa.hash() - const kids = multihash.toB58String(keyId) - expect(kids).to.equal(peer.toB58String()) + const kids = base58btc.encode(keyId).substring(1) + expect(kids).to.equal(peer.toString()) }) it('encoded public key with JWT', async () => { @@ -54,16 +57,20 @@ describe('peer ID', () => { alg: 'RS256', kid: '2011-04-29' } - const rsa = new rsaClass.RsaPublicKey(jwk) + const rsa = new supportedKeys.rsa.RsaPublicKey(jwk) const keyId = await rsa.hash() - const kids = multihash.toB58String(keyId) - expect(kids).to.equal(peer.toB58String()) + const kids = base58btc.encode(keyId).substring(1) + expect(kids).to.equal(peer.toString()) }) it('decoded private key', async () => { + if (peer.privateKey == null) { + throw new Error('PrivateKey missing from PeerId') + } + // get protobuf version of the private key - const privateKeyProtobuf = peer.marshalPrivKey() - const key = await crypto.keys.unmarshalPrivateKey(privateKeyProtobuf) + const privateKeyProtobuf = peer.privateKey + const key = await unmarshalPrivateKey(privateKeyProtobuf) expect(key).to.exist() }) }) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f296f99 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist", + "emitDeclarationOnly": false, + "module": "ES2020" + }, + "include": [ + "src", + "test" + ] +}