Skip to content

Commit

Permalink
CMS - PKCS #7 (#19)
Browse files Browse the repository at this point in the history
CMS - PKCS #7
  • Loading branch information
richardschneider authored and daviddias committed Jan 29, 2018
1 parent acf48a8 commit 5560669
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 14 deletions.
4 changes: 0 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)!
Expand Down
142 changes: 142 additions & 0 deletions src/cms.js
Original file line number Diff line number Diff line change
@@ -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
30 changes: 23 additions & 7 deletions src/keychain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand All @@ -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'
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

/**
Expand All @@ -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.
*
Expand Down
70 changes: 70 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions test/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ describe('browser', () => {
})

require('./keychain.spec')(datastore1, datastore2)
require('./cms-interop')(datastore2)
require('./peerid')
})
73 changes: 73 additions & 0 deletions test/cms-interop.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
}
Loading

0 comments on commit 5560669

Please sign in to comment.