diff --git a/src/keychain/index.js b/src/keychain/index.js index 05c187a412..97d1a0d682 100644 --- a/src/keychain/index.js +++ b/src/keychain/index.js @@ -1,6 +1,9 @@ /* eslint max-nested-callbacks: ["error", 5] */ 'use strict' - +const debug = require('debug') +const log = Object.assign(debug('libp2p:keychain'), { + error: debug('libp2p:keychain:err') +}) const sanitize = require('sanitize-filename') const mergeOptions = require('merge-options') const crypto = require('libp2p-crypto') @@ -503,6 +506,55 @@ class Keychain { return throwDelayed(errcode(new Error(`Key '${name}' does not exist. ${err.message}`), 'ERR_KEY_NOT_FOUND')) } } + + /** + * Rotate keychain password and re-encrypt all assosciated keys + * + * @param {string} oldPass - The old local keychain password + * @param {string} newPass - The new local keychain password + */ + async rotateKeychainPass (oldPass, newPass) { + if (typeof oldPass !== 'string') { + return throwDelayed(errcode(new Error(`Invalid old pass type '${typeof oldPass}'`), 'ERR_INVALID_OLD_PASS_TYPE')) + } + if (typeof newPass !== 'string') { + return throwDelayed(errcode(new Error(`Invalid new pass type '${typeof newPass}'`), 'ERR_INVALID_NEW_PASS_TYPE')) + } + if (newPass.length < 20) { + return throwDelayed(errcode(new Error(`Invalid pass length ${newPass.length}`), 'ERR_INVALID_PASS_LENGTH')) + } + log('recreating keychain') + const oldDek = privates.get(this).dek + this.opts.pass = newPass + const newDek = newPass + ? crypto.pbkdf2( + newPass, + this.opts.dek.salt, + this.opts.dek.iterationCount, + this.opts.dek.keyLength, + this.opts.dek.hash) + : '' + privates.set(this, { dek: newDek }) + const keys = await this.listKeys() + for (const key of keys) { + const res = await this.store.get(DsName(key.name)) + const pem = uint8ArrayToString(res) + const privateKey = await crypto.keys.import(pem, oldDek) + const password = newDek.toString() + const keyAsPEM = await privateKey.export(password) + + // Update stored key + const batch = this.store.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') + } } module.exports = Keychain diff --git a/test/keychain/keychain.spec.js b/test/keychain/keychain.spec.js index 99ade94224..9c5047a964 100644 --- a/test/keychain/keychain.spec.js +++ b/test/keychain/keychain.spec.js @@ -9,9 +9,10 @@ const uint8ArrayToString = require('uint8arrays/to-string') const peerUtils = require('../utils/creators/peer') -const { MemoryDatastore } = require('interface-datastore') +const { MemoryDatastore, Key } = require('interface-datastore') const Keychain = require('../../src/keychain') const PeerId = require('peer-id') +const crypto = require('libp2p-crypto') describe('keychain', () => { const passPhrase = 'this is not a secure phrase' @@ -492,6 +493,88 @@ describe('keychain', () => { expect(key).to.have.property('id', rsaKeyInfo.id) }) }) + + describe('rotate keychain passphrase', () => { + let oldPass + let kc + let options + let ds + 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 Keychain(ds, options) + await ds.open() + }) + + it('should validate newPass is a string', async () => { + try { + await kc.rotateKeychainPass(oldPass, 1234567890) + } catch (err) { + expect(err).to.exist() + } + }) + + it('should validate oldPass is a string', async () => { + try { + await kc.rotateKeychainPass(1234, 'newInsecurePassword1') + } catch (err) { + expect(err).to.exist() + } + }) + + it('should validate newPass is at least 20 characters', async () => { + try { + await kc.rotateKeychainPass(oldPass, 'not20Chars') + } catch (err) { + 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 + ? crypto.pbkdf2( + options.pass, + options.dek.salt, + options.dek.iterationCount, + options.dek.keyLength, + options.dek.hash) + : '' + + // eslint-disable-next-line no-constant-condition + const newDek = 'newInsecurePassphrase' + ? crypto.pbkdf2( + 'newInsecurePassphrase', + options.dek.salt, + options.dek.iterationCount, + options.dek.keyLength, + options.dek.hash) + : '' + + // 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', () => {