diff --git a/.travis.yml b/.travis.yml index 5102ee5f..a456ff12 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,15 +14,11 @@ matrix: script: - npm run lint - npm run test - - npm run coverage before_script: - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start -after_success: - - npm run coverage-publish - addons: firefox: 'latest' apt: diff --git a/README.md b/README.md index a4a0a0ac..a3b1c33e 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ A naming service for a key Cryptographically protected messages -- `cms.createAnonymousEncryptedData (name, plain, callback)` -- `cms.readData (cmsData, callback)` +- `cms.encrypt (name, plain, callback)` +- `cms.decrypt (cmsData, callback)` ### KeyInfo @@ -105,6 +105,10 @@ const defaultOptions = { The actual physical storage of an encrypted key is left to implementations of [interface-datastore](https://github.com/ipfs/interface-datastore/). A key benifit is that now the key chain can be used in browser with the [js-datastore-level](https://github.com/ipfs/js-datastore-level) implementation. +### Cryptographic Message Syntax (CMS) + +CMS, aka [PKCS #7](https://en.wikipedia.org/wiki/PKCS) and [RFC 5652](https://tools.ietf.org/html/rfc5652), describes an encapsulation syntax for data protection. It is used to digitally sign, digest, authenticate, or encrypt arbitrary message content. Basically, `cms.encrypt` creates a DER message that can be only be read by someone holding the private key. + ## Contribute Feel free to join in. All welcome. Open an [issue](https://github.com/libp2p/js-libp2p-crypto/issues)! diff --git a/src/cms.js b/src/cms.js new file mode 100644 index 00000000..937063cc --- /dev/null +++ b/src/cms.js @@ -0,0 +1,142 @@ +'use strict' + +const async = require('async') +const forge = require('node-forge') +const util = require('./util') + +/** + * 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 new Error('keychain is 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. + * @param {function(Error, Buffer)} callback + * @returns {undefined} + */ + encrypt (name, plain, callback) { + const self = this + const done = (err, result) => async.setImmediate(() => callback(err, result)) + + if (!Buffer.isBuffer(plain)) { + return done(new Error('Plain data must be a Buffer')) + } + + async.series([ + (cb) => self.keychain.findKeyByName(name, cb), + (cb) => self.keychain._getPrivateKey(name, cb) + ], (err, results) => { + if (err) return done(err) + + let key = results[0] + let pem = results[1] + try { + const privateKey = forge.pki.decryptRsaPrivateKey(pem, self.keychain._()) + util.certificateForKey(key, privateKey, (err, certificate) => { + if (err) return callback(err) + + // 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() + done(null, Buffer.from(der, 'binary')) + }) + } catch (err) { + done(err) + } + }) + } + + /** + * 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. + * @param {function(Error, Buffer)} callback + * @returns {undefined} + */ + decrypt (cmsData, callback) { + const done = (err, result) => async.setImmediate(() => callback(err, result)) + + if (!Buffer.isBuffer(cmsData)) { + return done(new Error('CMS data is required')) + } + + const self = this + let cms + try { + const buf = forge.util.createBuffer(cmsData.toString('binary')) + const obj = forge.asn1.fromDer(buf) + cms = forge.pkcs7.messageFromAsn1(obj) + } catch (err) { + return done(new Error('Invalid CMS: ' + err.message)) + } + + // 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 + } + }) + async.detect( + recipients, + (r, cb) => self.keychain.findKeyById(r.keyId, (err, info) => cb(null, !err && info)), + (err, r) => { + if (err) return done(err) + if (!r) { + const missingKeys = recipients.map(r => r.keyId) + err = new Error('Decryption needs one of the key(s): ' + missingKeys.join(', ')) + err.missingKeys = missingKeys + return done(err) + } + + async.waterfall([ + (cb) => self.keychain.findKeyById(r.keyId, cb), + (key, cb) => self.keychain._getPrivateKey(key.name, cb) + ], (err, pem) => { + if (err) return done(err) + + const privateKey = forge.pki.decryptRsaPrivateKey(pem, self.keychain._()) + cms.decrypt(r.recipient, privateKey) + done(null, Buffer.from(cms.content.getBytes(), 'binary')) + }) + } + ) + } +} + +module.exports = CMS diff --git a/src/keychain.js b/src/keychain.js index 28148341..41f5c1c4 100644 --- a/src/keychain.js +++ b/src/keychain.js @@ -6,6 +6,7 @@ const deepmerge = require('deepmerge') const crypto = require('libp2p-crypto') const DS = require('interface-datastore') const pull = require('pull-stream') +const CMS = require('./cms') const keyPrefix = '/pkcs8/' const infoPrefix = '/info/' @@ -21,7 +22,7 @@ const defaultOptions = { // See https://cryptosense.com/parametesr-choice-for-pbkdf2/ dek: { keyLength: 512 / 8, - iterationCount: 1000, + iterationCount: 10000, salt: 'you should override this value with a crypto secure random number', hash: 'sha2-512' } @@ -86,8 +87,8 @@ function DsInfoName (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 + * - '/info/*key-name*', contains the KeyInfo for the key + * - '/pkcs8/*key-name*', contains the PKCS #8 for the key * */ class Keychain { @@ -130,12 +131,17 @@ class Keychain { } /** - * The default options for a keychain. + * Gets an object that can encrypt/decrypt protected data + * using the Cryptographic Message Syntax (CMS). * - * @returns {object} + * CMS describes an encapsulation syntax for data protection. It + * is used to digitally sign, digest, authenticate, or encrypt + * arbitrary message content. + * + * @returns {CMS} */ - static get options () { - return defaultOptions + get cms () { + return new CMS(this) } /** @@ -150,6 +156,16 @@ class Keychain { 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. * diff --git a/src/util.js b/src/util.js new file mode 100644 index 00000000..9aa248ff --- /dev/null +++ b/src/util.js @@ -0,0 +1,70 @@ +'use strict' + +const forge = require('node-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 + * @param {function(Error, Certificate)} callback + * @returns {undefined} + */ +exports.certificateForKey = (key, privateKey, callback) => { + 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 callback(null, cert) +} diff --git a/test/browser.js b/test/browser.js index 4e08b137..e1aa2b00 100644 --- a/test/browser.js +++ b/test/browser.js @@ -23,5 +23,6 @@ describe('browser', () => { }) require('./keychain.spec')(datastore1, datastore2) + require('./cms-interop')(datastore2) require('./peerid') }) diff --git a/test/cms-interop.js b/test/cms-interop.js new file mode 100644 index 00000000..a7449984 --- /dev/null +++ b/test/cms-interop.js @@ -0,0 +1,73 @@ +/* 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((done) => { + ks = new Keychain(datastore, { passPhrase: passPhrase }) + done() + }) + + const plainData = Buffer.from('This is a message from Alice to Bob') + + it('imports openssl key', function (done) { + 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----- +` + ks.importKey(aliceKeyName, alice, 'mypassword', (err, key) => { + expect(err).to.not.exist() + expect(key.name).to.equal(aliceKeyName) + expect(key.id).to.equal(aliceKid) + done() + }) + }) + + it('decrypts node-forge example', (done) => { + const example = ` +MIIBcwYJKoZIhvcNAQcDoIIBZDCCAWACAQAxgfowgfcCAQAwYDBbMQ0wCwYDVQQK +EwRpcGZzMREwDwYDVQQLEwhrZXlzdG9yZTE3MDUGA1UEAxMuUW1OekJxUHdwNDJI +WkpjY3NMdGM0b2s2TGpaQXNwY2tnczJkdTV0VG1qUGZGQQIBATANBgkqhkiG9w0B +AQEFAASBgLKXCZQYmMLuQ8m0Ex/rr3KNK+Q2+QG1zIbIQ9MFPUNQ7AOgGOHyL40k +d1gr188EHuiwd90PafZoQF9VRSX9YtwGNqAE8+LD8VaITxCFbLGRTjAqeOUHR8cO +knU1yykWGkdlbclCuu0NaAfmb8o0OX50CbEKZB7xmsv8tnqn0H0jMF4GCSqGSIb3 +DQEHATAdBglghkgBZQMEASoEEP/PW1JWehQx6/dsLkp/Mf+gMgQwFM9liLTqC56B +nHILFmhac/+a/StQOKuf9dx5qXeGvt9LnwKuGGSfNX4g+dTkoa6N +` + ks.cms.decrypt(Buffer.from(example, 'base64'), (err, plain) => { + expect(err).to.not.exist() + expect(plain).to.exist() + expect(plain.toString()).to.equal(plainData.toString()) + done() + }) + }) + }) +} diff --git a/test/keychain.spec.js b/test/keychain.spec.js index 32112dc5..ae78cb1e 100644 --- a/test/keychain.spec.js +++ b/test/keychain.spec.js @@ -16,11 +16,12 @@ module.exports = (datastore1, datastore2) => { const rsaKeyName = 'tajné jméno' const renamedRsaKeyName = 'ชื่อลับ' let rsaKeyInfo - // let emptyKeystore + let emptyKeystore let ks before((done) => { ks = new Keychain(datastore2, { passPhrase: passPhrase }) + emptyKeystore = new Keychain(datastore1, { passPhrase: passPhrase }) done() }) @@ -169,6 +170,72 @@ module.exports = (datastore1, datastore2) => { }) }) + describe('CMS protected data', () => { + const plainData = Buffer.from('This is a message from Alice to Bob') + let cms + + it('service is available', (done) => { + expect(ks).to.have.property('cms') + done() + }) + + it('requires a key', (done) => { + ks.cms.encrypt('no-key', plainData, (err, msg) => { + expect(err).to.exist() + done() + }) + }) + + it('requires plain data as a Buffer', (done) => { + ks.cms.encrypt(rsaKeyName, 'plain data', (err, msg) => { + expect(err).to.exist() + done() + }) + }) + + it('encrypts', (done) => { + ks.cms.encrypt(rsaKeyName, plainData, (err, msg) => { + expect(err).to.not.exist() + expect(msg).to.exist() + expect(msg).to.be.instanceOf(Buffer) + cms = msg + done() + }) + }) + + it('is a PKCS #7 message', (done) => { + ks.cms.decrypt('not CMS', (err) => { + expect(err).to.exist() + done() + }) + }) + + it('is a PKCS #7 binary message', (done) => { + ks.cms.decrypt(plainData, (err) => { + expect(err).to.exist() + done() + }) + }) + + it('cannot be read without the key', (done) => { + emptyKeystore.cms.decrypt(cms, (err, plain) => { + expect(err).to.exist() + expect(err).to.have.property('missingKeys') + expect(err.missingKeys).to.eql([rsaKeyInfo.id]) + done() + }) + }) + + it('can be read with the key', (done) => { + ks.cms.decrypt(cms, (err, plain) => { + expect(err).to.not.exist() + expect(plain).to.exist() + expect(plain.toString()).to.equal(plainData.toString()) + done() + }) + }) + }) + describe('exported key', () => { let pemKey diff --git a/test/node.js b/test/node.js index b003a7c8..6ca293ee 100644 --- a/test/node.js +++ b/test/node.js @@ -30,5 +30,6 @@ describe('node', () => { }) require('./keychain.spec')(datastore1, datastore2) + require('./cms-interop')(datastore2) require('./peerid') })