From 946b046440ca125acd8616b816c5b5f94b1e9ee0 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 3 Nov 2020 10:22:03 -0700 Subject: [PATCH] feat: pubsub: add global signature policy (#66) BREAKING CHANGE: `signMessages` and `strictSigning` pubsub configuration options replaced with a `globalSignaturePolicy` option --- package.json | 1 + src/pubsub/README.md | 39 ++++++++---- src/pubsub/errors.js | 42 ++++++++++++- src/pubsub/index.js | 88 +++++++++++++++++++--------- src/pubsub/signature-policy.js | 28 +++++++++ src/pubsub/utils.js | 10 ++++ test/pubsub/message.spec.js | 37 ++++++++---- test/pubsub/topic-validators.spec.js | 12 +--- 8 files changed, 200 insertions(+), 57 deletions(-) create mode 100644 src/pubsub/signature-policy.js diff --git a/package.json b/package.json index 6a74c83..2fa2255 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "libp2p-tcp": "^0.15.0", "multiaddr": "^8.0.0", "multibase": "^3.0.0", + "multihashes": "^3.0.1", "p-defer": "^3.0.0", "p-limit": "^2.3.0", "p-wait-for": "^3.1.0", diff --git a/src/pubsub/README.md b/src/pubsub/README.md index 2e7df62..5f7782f 100644 --- a/src/pubsub/README.md +++ b/src/pubsub/README.md @@ -11,6 +11,9 @@ Table of Contents * [Extend interface](#extend-interface) * [Example](#example) * [API](#api) + * [Constructor](#constructor) + * [new Pubsub(options)](#new-pubsuboptions) + * [Parameters](#parameters) * [Start](#start) * [pubsub.start()](#pubsubstart) * [Returns](#returns) @@ -19,24 +22,24 @@ Table of Contents * [Returns](#returns-1) * [Publish](#publish) * [pubsub.publish(topics, message)](#pubsubpublishtopics-message) - * [Parameters](#parameters) + * [Parameters](#parameters-1) * [Returns](#returns-2) * [Subscribe](#subscribe) * [pubsub.subscribe(topic)](#pubsubsubscribetopic) - * [Parameters](#parameters-1) + * [Parameters](#parameters-2) * [Unsubscribe](#unsubscribe) * [pubsub.unsubscribe(topic)](#pubsubunsubscribetopic) - * [Parameters](#parameters-2) + * [Parameters](#parameters-3) * [Get Topics](#get-topics) * [pubsub.getTopics()](#pubsubgettopics) * [Returns](#returns-3) * [Get Peers Subscribed to a topic](#get-peers-subscribed-to-a-topic) * [pubsub.getSubscribers(topic)](#pubsubgetsubscriberstopic) - * [Parameters](#parameters-3) + * [Parameters](#parameters-4) * [Returns](#returns-4) * [Validate](#validate) * [pubsub.validate(message)](#pubsubvalidatemessage) - * [Parameters](#parameters-4) + * [Parameters](#parameters-5) * [Returns](#returns-5) * [Test suite usage](#test-suite-usage) @@ -49,7 +52,7 @@ You can check the following implementations as examples for building your own pu ## Interface usage -`interface-pubsub` abstracts the implementation protocol registration within `libp2p` and takes care of all the protocol connections and streams, as well as the subscription management. This way, a pubsub implementation can focus on its message routing algorithm, instead of also needing to create the setup for it. +`interface-pubsub` abstracts the implementation protocol registration within `libp2p` and takes care of all the protocol connections and streams, as well as the subscription management and the features describe in the libp2p [pubsub specs](https://github.com/libp2p/specs/tree/master/pubsub). This way, a pubsub implementation can focus on its message routing algorithm, instead of also needing to create the setup for it. ### Extend interface @@ -74,7 +77,7 @@ All the remaining functions **MUST NOT** be overwritten. The following example aims to show how to create your pubsub implementation extending this base protocol. The pubsub implementation will handle the subscriptions logic. ```JavaScript -const Pubsub = require('libp2p-pubsub') +const Pubsub = require('libp2p-interfaces/src/pubsub') class PubsubImplementation extends Pubsub { constructor({ libp2p, options }) @@ -82,8 +85,7 @@ class PubsubImplementation extends Pubsub { debugName: 'libp2p:pubsub', multicodecs: '/pubsub-implementation/1.0.0', libp2p, - signMessages: options.signMessages, - strictSigning: options.strictSigning + globalSigningPolicy: options.globalSigningPolicy }) } @@ -98,6 +100,23 @@ class PubsubImplementation extends Pubsub { The interface aims to specify a common interface that all pubsub router implementation should follow. A pubsub router implementation should extend the [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter). When peers receive pubsub messages, these messages will be emitted by the event emitter where the `eventName` will be the `topic` associated with the message. +### Constructor + +The base class constructor configures the pubsub instance for use with a libp2p instance. It includes settings for logging, signature policies, etc. + +#### `new Pubsub({options})` + +##### Parameters + +| Name | Type | Description | Default | +|------|------|-------------|---------| +| options.libp2p | `Libp2p` | libp2p instance | required, no default | +| options.debugName | `string` | log namespace | required, no default | +| options.multicodecs | `string \| Array` | protocol identifier(s) | required, no default | +| options.globalSignaturePolicy | `'StrictSign' \| 'StrictNoSign'` | signature policy to be globally applied | `'StrictSign'` | +| options.canRelayMessage | `boolean` | if can relay messages if not subscribed | `false` | +| options.emitSelf | `boolean` | if `publish` should emit to self, if subscribed | `false` | + ### Start Starts the pubsub subsystem. The protocol will be registered to `libp2p`, which will result in pubsub being notified when peers who support the protocol connect/disconnect to `libp2p`. @@ -185,7 +204,7 @@ Get a list of the [PeerId](https://github.com/libp2p/js-peer-id) strings that ar ### Validate -Validates the signature of a message. +Validates a message according to the signature policy and topic-specific validation function. #### `pubsub.validate(message)` diff --git a/src/pubsub/errors.js b/src/pubsub/errors.js index 24f0bbf..3c62352 100644 --- a/src/pubsub/errors.js +++ b/src/pubsub/errors.js @@ -1,6 +1,46 @@ 'use strict' exports.codes = { + /** + * Signature policy is invalid + */ + ERR_INVALID_SIGNATURE_POLICY: 'ERR_INVALID_SIGNATURE_POLICY', + /** + * Signature policy is unhandled + */ + ERR_UNHANDLED_SIGNATURE_POLICY: 'ERR_UNHANDLED_SIGNATURE_POLICY', + + // Strict signing codes + + /** + * Message expected to have a `signature`, but doesn't + */ ERR_MISSING_SIGNATURE: 'ERR_MISSING_SIGNATURE', - ERR_INVALID_SIGNATURE: 'ERR_INVALID_SIGNATURE' + /** + * Message expected to have a `seqno`, but doesn't + */ + ERR_MISSING_SEQNO: 'ERR_MISSING_SEQNO', + /** + * Message `signature` is invalid + */ + ERR_INVALID_SIGNATURE: 'ERR_INVALID_SIGNATURE', + + // Strict no-signing codes + + /** + * Message expected to not have a `from`, but does + */ + ERR_UNEXPECTED_FROM: 'ERR_UNEXPECTED_FROM', + /** + * Message expected to not have a `signature`, but does + */ + ERR_UNEXPECTED_SIGNATURE: 'ERR_UNEXPECTED_SIGNATURE', + /** + * Message expected to not have a `key`, but does + */ + ERR_UNEXPECTED_KEY: 'ERR_UNEXPECTED_KEY', + /** + * Message expected to not have a `seqno`, but does + */ + ERR_UNEXPECTED_SEQNO: 'ERR_UNEXPECTED_SEQNO' } diff --git a/src/pubsub/index.js b/src/pubsub/index.js index 8728369..a2131a7 100644 --- a/src/pubsub/index.js +++ b/src/pubsub/index.js @@ -13,6 +13,7 @@ const { codes } = require('./errors') */ const message = require('./message') const PeerStreams = require('./peer-streams') +const { SignaturePolicy } = require('./signature-policy') const utils = require('./utils') const { @@ -44,8 +45,7 @@ class PubsubBaseProtocol extends EventEmitter { * @param {String} props.debugName log namespace * @param {Array|string} props.multicodecs protocol identificers to connect * @param {Libp2p} props.libp2p - * @param {boolean} [props.signMessages = true] if messages should be signed - * @param {boolean} [props.strictSigning = true] if message signing should be required + * @param {SignaturePolicy} [props.globalSignaturePolicy = SignaturePolicy.StrictSign] defines how signatures should be handled * @param {boolean} [props.canRelayMessage = false] if can relay messages not subscribed * @param {boolean} [props.emitSelf = false] if publish should emit to self, if subscribed * @abstract @@ -54,8 +54,7 @@ class PubsubBaseProtocol extends EventEmitter { debugName, multicodecs, libp2p, - signMessages = true, - strictSigning = true, + globalSignaturePolicy = SignaturePolicy.StrictSign, canRelayMessage = false, emitSelf = false }) { @@ -109,14 +108,17 @@ class PubsubBaseProtocol extends EventEmitter { */ this.peers = new Map() - // Message signing - this.signMessages = signMessages + // validate signature policy + if (!SignaturePolicy[globalSignaturePolicy]) { + throw errcode(new Error('Invalid global signature policy'), codes.ERR_INVALID_SIGUATURE_POLICY) + } /** - * If message signing should be required for incoming messages - * @type {boolean} + * The signature policy to follow by default + * + * @type {SignaturePolicy} */ - this.strictSigning = strictSigning + this.globalSignaturePolicy = globalSignaturePolicy /** * If router can relay received messages, even if not subscribed @@ -440,7 +442,15 @@ class PubsubBaseProtocol extends EventEmitter { * @returns {Uint8Array} message id as bytes */ getMsgId (msg) { - return utils.msgId(msg.from, msg.seqno) + const signaturePolicy = this.globalSignaturePolicy + switch (signaturePolicy) { + case SignaturePolicy.StrictSign: + return utils.msgId(msg.from, msg.seqno) + case SignaturePolicy.StrictNoSign: + return utils.noSignMsgId(msg.data) + default: + throw errcode(new Error('Cannot get message id: unhandled signature policy: ' + signaturePolicy), codes.ERR_UNHANDLED_SIGNATURE_POLICY) + } } /** @@ -511,16 +521,36 @@ class PubsubBaseProtocol extends EventEmitter { * @returns {Promise} */ async validate (message) { // eslint-disable-line require-await - // If strict signing is on and we have no signature, abort - if (this.strictSigning && !message.signature) { - throw errcode(new Error('Signing required and no signature was present'), codes.ERR_MISSING_SIGNATURE) + const signaturePolicy = this.globalSignaturePolicy + switch (signaturePolicy) { + case SignaturePolicy.StrictNoSign: + if (message.from) { + throw errcode(new Error('StrictNoSigning: from should not be present'), codes.ERR_UNEXPECTED_FROM) + } + if (message.signature) { + throw errcode(new Error('StrictNoSigning: signature should not be present'), codes.ERR_UNEXPECTED_SIGNATURE) + } + if (message.key) { + throw errcode(new Error('StrictNoSigning: key should not be present'), codes.ERR_UNEXPECTED_KEY) + } + if (message.seqno) { + throw errcode(new Error('StrictNoSigning: seqno should not be present'), codes.ERR_UNEXPECTED_SEQNO) + } + break + case SignaturePolicy.StrictSign: + if (!message.signature) { + throw errcode(new Error('StrictSigning: Signing required and no signature was present'), codes.ERR_MISSING_SIGNATURE) + } + if (!message.seqno) { + throw errcode(new Error('StrictSigning: Signing required and no seqno was present'), codes.ERR_MISSING_SEQNO) + } + if (!(await verifySignature(message))) { + throw errcode(new Error('StrictSigning: Invalid message signature'), codes.ERR_INVALID_SIGNATURE) + } + break + default: + throw errcode(new Error('Cannot validate message: unhandled signature policy: ' + signaturePolicy), codes.ERR_UNHANDLED_SIGNATURE_POLICY) } - - // Check the message signature if present - if (message.signature && !(await verifySignature(message))) { - throw errcode(new Error('Invalid message signature'), codes.ERR_INVALID_SIGNATURE) - } - for (const topic of message.topicIDs) { const validatorFn = this.topicValidators.get(topic) if (!validatorFn) { @@ -538,11 +568,16 @@ class PubsubBaseProtocol extends EventEmitter { * @returns {Promise} */ _buildMessage (message) { - const msg = utils.normalizeOutRpcMessage(message) - if (this.signMessages) { - return signMessage(this.peerId, msg) - } else { - return message + const signaturePolicy = this.globalSignaturePolicy + switch (signaturePolicy) { + case SignaturePolicy.StrictSign: + message.from = this.peerId.toB58String() + message.seqno = utils.randomSeqno() + return signMessage(this.peerId, utils.normalizeOutRpcMessage(message)) + case SignaturePolicy.StrictNoSign: + return message + default: + throw errcode(new Error('Cannot build message: unhandled signature policy: ' + signaturePolicy), codes.ERR_UNHANDLED_SIGNATURE_POLICY) } } @@ -586,13 +621,11 @@ class PubsubBaseProtocol extends EventEmitter { const from = this.peerId.toB58String() let msgObject = { receivedFrom: from, - from: from, data: message, - seqno: utils.randomSeqno(), topicIDs: [topic] } - // ensure that any operations performed on the message will include the signature + // ensure that the message follows the signature policy const outMsg = await this._buildMessage(msgObject) msgObject = utils.normalizeInRpcMessage(outMsg) @@ -666,3 +699,4 @@ class PubsubBaseProtocol extends EventEmitter { module.exports = PubsubBaseProtocol module.exports.message = message module.exports.utils = utils +module.exports.SignaturePolicy = SignaturePolicy diff --git a/src/pubsub/signature-policy.js b/src/pubsub/signature-policy.js new file mode 100644 index 0000000..0b5fa8c --- /dev/null +++ b/src/pubsub/signature-policy.js @@ -0,0 +1,28 @@ +'use strict' + +/** + * Enum for Signature Policy + * Details how message signatures are produced/consumed + */ +exports.SignaturePolicy = { + /** + * On the producing side: + * * Build messages with the signature, key (from may be enough for certain inlineable public key types), from and seqno fields. + * + * On the consuming side: + * * Enforce the fields to be present, reject otherwise. + * * Propagate only if the fields are valid and signature can be verified, reject otherwise. + */ + StrictSign: 'StrictSign', + /** + * On the producing side: + * * Build messages without the signature, key, from and seqno fields. + * * The corresponding protobuf key-value pairs are absent from the marshalled message, not just empty. + * + * On the consuming side: + * * Enforce the fields to be absent, reject otherwise. + * * Propagate only if the fields are absent, reject otherwise. + * * A message_id function will not be able to use the above fields, and should instead rely on the data field. A commonplace strategy is to calculate a hash. + */ + StrictNoSign: 'StrictNoSign' +} diff --git a/src/pubsub/utils.js b/src/pubsub/utils.js index bc51005..ae7140f 100644 --- a/src/pubsub/utils.js +++ b/src/pubsub/utils.js @@ -4,6 +4,7 @@ const randomBytes = require('libp2p-crypto/src/random-bytes') const uint8ArrayToString = require('uint8arrays/to-string') const uint8ArrayFromString = require('uint8arrays/from-string') const PeerId = require('peer-id') +const multihash = require('multihashes') exports = module.exports /** @@ -32,6 +33,15 @@ exports.msgId = (from, seqno) => { return msgId } +/** + * Generate a message id, based on message `data`. + * + * @param {Uint8Array} data + * @returns {Uint8Array} + * @private + */ +exports.noSignMsgId = (data) => multihash.encode(data, 'sha2') + /** * Check if any member of the first set is also a member * of the second set. diff --git a/test/pubsub/message.spec.js b/test/pubsub/message.spec.js index e2e189f..a1e9f1e 100644 --- a/test/pubsub/message.spec.js +++ b/test/pubsub/message.spec.js @@ -5,7 +5,7 @@ const { expect } = require('aegir/utils/chai') const sinon = require('sinon') const PubsubBaseImpl = require('../../src/pubsub') -const { randomSeqno } = require('../../src/pubsub/utils') +const { SignaturePolicy } = require('../../src/pubsub/signature-policy') const { createPeerId, mockRegistrar @@ -34,9 +34,7 @@ describe('pubsub base messages', () => { it('_buildMessage normalizes and signs messages', async () => { const message = { receivedFrom: peerId.id, - from: peerId.id, data: 'hello', - seqno: randomSeqno(), topicIDs: ['test-topic'] } @@ -44,27 +42,46 @@ describe('pubsub base messages', () => { expect(pubsub.validate(signedMessage)).to.not.be.rejected() }) - it('validate with strict signing off will validate a present signature', async () => { + it('validate with StrictNoSign will reject a message with from, signature, key, seqno present', async () => { const message = { receivedFrom: peerId.id, - from: peerId.id, data: 'hello', - seqno: randomSeqno(), topicIDs: ['test-topic'] } - sinon.stub(pubsub, 'strictSigning').value(false) + sinon.stub(pubsub, 'globalSignaturePolicy').value(SignaturePolicy.StrictSign) + + const signedMessage = await pubsub._buildMessage(message) + + sinon.stub(pubsub, 'globalSignaturePolicy').value(SignaturePolicy.StrictNoSign) + await expect(pubsub.validate(signedMessage)).to.be.rejected() + delete signedMessage.from + await expect(pubsub.validate(signedMessage)).to.be.rejected() + delete signedMessage.signature + await expect(pubsub.validate(signedMessage)).to.be.rejected() + delete signedMessage.key + await expect(pubsub.validate(signedMessage)).to.be.rejected() + delete signedMessage.seqno + await expect(pubsub.validate(signedMessage)).to.not.be.rejected() + }) + + it('validate with StrictNoSign will validate a message without a signature, key, and seqno', async () => { + const message = { + receivedFrom: peerId.id, + data: 'hello', + topicIDs: ['test-topic'] + } + + sinon.stub(pubsub, 'globalSignaturePolicy').value(SignaturePolicy.StrictNoSign) const signedMessage = await pubsub._buildMessage(message) expect(pubsub.validate(signedMessage)).to.not.be.rejected() }) - it('validate with strict signing requires a signature', async () => { + it('validate with StrictSign requires a signature', async () => { const message = { receivedFrom: peerId.id, - from: peerId.id, data: 'hello', - seqno: randomSeqno(), topicIDs: ['test-topic'] } diff --git a/test/pubsub/topic-validators.spec.js b/test/pubsub/topic-validators.spec.js index 86e0db8..63cf842 100644 --- a/test/pubsub/topic-validators.spec.js +++ b/test/pubsub/topic-validators.spec.js @@ -10,8 +10,8 @@ const PeerId = require('peer-id') const uint8ArrayEquals = require('uint8arrays/equals') const uint8ArrayFromString = require('uint8arrays/from-string') -const { utils } = require('../../src/pubsub') const PeerStreams = require('../../src/pubsub/peer-streams') +const { SignaturePolicy } = require('../../src/pubsub/signature-policy') const { createPeerId, @@ -30,6 +30,8 @@ describe('topic validators', () => { pubsub = new PubsubImplementation(protocol, { peerId: peerId, registrar: mockRegistrar + }, { + globalSignaturePolicy: SignaturePolicy.StrictNoSign }) pubsub.start() @@ -42,8 +44,6 @@ describe('topic validators', () => { it('should filter messages by topic validator', async () => { // use _publish.callCount() to see if a message is valid or not sinon.spy(pubsub, '_publish') - // Disable strict signing - sinon.stub(pubsub, 'strictSigning').value(false) sinon.stub(pubsub.peers, 'get').returns({}) const filteredTopic = 't' const peer = new PeerStreams({ id: await PeerId.create() }) @@ -59,9 +59,7 @@ describe('topic validators', () => { const validRpc = { subscriptions: [], msgs: [{ - from: peer.id.toBytes(), data: uint8ArrayFromString('a message'), - seqno: utils.randomSeqno(), topicIDs: [filteredTopic] }] } @@ -76,9 +74,7 @@ describe('topic validators', () => { const invalidRpc = { subscriptions: [], msgs: [{ - from: peer.id.toBytes(), data: uint8ArrayFromString('a different message'), - seqno: utils.randomSeqno(), topicIDs: [filteredTopic] }] } @@ -94,9 +90,7 @@ describe('topic validators', () => { const invalidRpc2 = { subscriptions: [], msgs: [{ - from: peer.id.toB58String(), data: uint8ArrayFromString('a different message'), - seqno: utils.randomSeqno(), topicIDs: [filteredTopic] }] }