diff --git a/CHANGELOG.md b/CHANGELOG.md index bea78c2803..7dcbd8d853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement DNS Discovery as per [EIP-1459](https://eips.ethereum.org/EIPS/eip-1459), with ENR records as defined in [31/WAKU2-ENR](https://rfc.vac.dev/spec/31/); Available by passing `{ bootstrap: { enrUrl: enrtree://... } }` to `Waku.create`. +- When using `addDecryptionKey`, + it is now possible to specify the decryption method and the content topics of the messages to decrypt; + this is to reduce the number of decryption attempt done and improve performance. ### Changed @@ -20,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Minimum node version changed to 16. - **Breaking**: Changed `Waku.create` bootstrap option from `{ bootstrap: boolean }` to `{ bootstrap: BootstrapOptions }`. Replace `{ boostrap: true }` with `{ boostrap: { default: true } }` to retain same behaviour. +- **Breaking**: `WakuMessage.decode` and `WakuMessage.decodeProto` now accepts method and content topics for the decryption key. + `WakuMessage.decode(bytes, [key])` becomes `WakuMessage.decode(bytes, [{key: key}])`. ### Fixed diff --git a/src/lib/waku.ts b/src/lib/waku.ts index 977b07c19b..d812abb131 100644 --- a/src/lib/waku.ts +++ b/src/lib/waku.ts @@ -18,11 +18,10 @@ import Ping from 'libp2p/src/ping'; import { Multiaddr, multiaddr } from 'multiaddr'; import PeerId from 'peer-id'; -import { Bootstrap } from './discovery'; -import { BootstrapOptions } from './discovery/bootstrap'; +import { Bootstrap, BootstrapOptions } from './discovery'; import { getPeersForProtocol } from './select_peer'; import { LightPushCodec, WakuLightPush } from './waku_light_push'; -import { WakuMessage } from './waku_message'; +import { DecryptionMethod, WakuMessage } from './waku_message'; import { RelayCodecs, WakuRelay } from './waku_relay'; import { RelayPingContentTopic } from './waku_relay/constants'; import { StoreCodec, WakuStore } from './waku_store'; @@ -135,7 +134,9 @@ export class Waku { this.stopKeepAlive(connection.remotePeer); }); - options?.decryptionKeys?.forEach(this.addDecryptionKey); + options?.decryptionKeys?.forEach((key) => { + this.addDecryptionKey(key); + }); } /** @@ -270,9 +271,12 @@ export class Waku { * * Strings must be in hex format. */ - addDecryptionKey(key: Uint8Array | string): void { - this.relay.addDecryptionKey(key); - this.store.addDecryptionKey(key); + addDecryptionKey( + key: Uint8Array | string, + options?: { method?: DecryptionMethod; contentTopics?: string[] } + ): void { + this.relay.addDecryptionKey(key, options); + this.store.addDecryptionKey(key, options); } /** diff --git a/src/lib/waku_message/index.spec.ts b/src/lib/waku_message/index.spec.ts index 5cc9bd4f40..bcfd3b0514 100644 --- a/src/lib/waku_message/index.spec.ts +++ b/src/lib/waku_message/index.spec.ts @@ -36,15 +36,15 @@ describe('Waku Message: Browser & Node', function () { fc.asyncProperty( fc.uint8Array({ minLength: 1 }), fc.uint8Array({ minLength: 32, maxLength: 32 }), - async (payload, privKey) => { - const publicKey = getPublicKey(privKey); + async (payload, key) => { + const publicKey = getPublicKey(key); const msg = await WakuMessage.fromBytes(payload, TestContentTopic, { encPublicKey: publicKey, }); const wireBytes = msg.encode(); - const actual = await WakuMessage.decode(wireBytes, [privKey]); + const actual = await WakuMessage.decode(wireBytes, [{ key }]); expect(actual?.payload).to.deep.equal(payload); } @@ -68,7 +68,9 @@ describe('Waku Message: Browser & Node', function () { }); const wireBytes = msg.encode(); - const actual = await WakuMessage.decode(wireBytes, [encPrivKey]); + const actual = await WakuMessage.decode(wireBytes, [ + { key: encPrivKey }, + ]); expect(actual?.payload).to.deep.equal(payload); expect(actual?.signaturePublicKey).to.deep.equal(sigPubKey); @@ -88,7 +90,7 @@ describe('Waku Message: Browser & Node', function () { }); const wireBytes = msg.encode(); - const actual = await WakuMessage.decode(wireBytes, [key]); + const actual = await WakuMessage.decode(wireBytes, [{ key }]); expect(actual?.payload).to.deep.equal(payload); } @@ -111,7 +113,7 @@ describe('Waku Message: Browser & Node', function () { }); const wireBytes = msg.encode(); - const actual = await WakuMessage.decode(wireBytes, [symKey]); + const actual = await WakuMessage.decode(wireBytes, [{ key: symKey }]); expect(actual?.payload).to.deep.equal(payload); expect(actual?.signaturePublicKey).to.deep.equal(sigPubKey); diff --git a/src/lib/waku_message/index.ts b/src/lib/waku_message/index.ts index 57b3ec8f65..8713904a88 100644 --- a/src/lib/waku_message/index.ts +++ b/src/lib/waku_message/index.ts @@ -12,6 +12,11 @@ import * as version_1 from './version_1'; const DefaultVersion = 0; const dbg = debug('waku:message'); +export enum DecryptionMethod { + Asymmetric = 'asymmetric', + Symmetric = 'symmetric', +} + export interface Options { /** * Timestamp to set on the message, defaults to now if not passed. @@ -44,7 +49,7 @@ export class WakuMessage { ) {} /** - * Create Message with a utf-8 string as payload. + * Create Message with an utf-8 string as payload. */ static async fromUtf8String( utf8: string, @@ -116,11 +121,15 @@ export class WakuMessage { * @params decryptionKeys If the payload is encrypted (version = 1), then the * keys are used to attempt decryption of the message. The passed key can either * be asymmetric private keys or symmetric keys, both method are tried for each - * key until the message is decrypted or combinations are ran out. + * key until the message is decrypted or combinations are run out. */ static async decode( bytes: Uint8Array, - decryptionKeys?: Uint8Array[] + decryptionKeys?: Array<{ + key: Uint8Array; + method?: DecryptionMethod; + contentTopic?: string[]; + }> ): Promise { const protoBuf = proto.WakuMessage.decode(Reader.create(bytes)); @@ -134,11 +143,15 @@ export class WakuMessage { * @params decryptionKeys If the payload is encrypted (version = 1), then the * keys are used to attempt decryption of the message. The passed key can either * be asymmetric private keys or symmetric keys, both method are tried for each - * key until the message is decrypted or combinations are ran out. + * key until the message is decrypted or combinations are run out. */ static async decodeProto( protoBuf: proto.WakuMessage, - decryptionKeys?: Uint8Array[] + decryptionKeys?: Array<{ + key: Uint8Array; + method?: DecryptionMethod; + contentTopics?: string[]; + }> ): Promise { if (protoBuf.payload === undefined) { dbg('Payload is undefined'); @@ -156,17 +169,55 @@ export class WakuMessage { // Returns a bunch of `undefined` and hopefully one decrypted result const allResults = await Promise.all( - decryptionKeys.map(async (privateKey) => { - try { - return await version_1.decryptSymmetric(payload, privateKey); - } catch (e) { - dbg('Failed to decrypt message using symmetric encryption', e); - try { - return await version_1.decryptAsymmetric(payload, privateKey); - } catch (e) { - dbg('Failed to decrypt message using asymmetric encryption', e); - return; + decryptionKeys.map(async ({ key, method, contentTopics }) => { + if ( + !contentTopics || + (protoBuf.contentTopic && + contentTopics.includes(protoBuf.contentTopic)) + ) { + switch (method) { + case DecryptionMethod.Asymmetric: + try { + return await version_1.decryptAsymmetric(payload, key); + } catch (e) { + dbg( + 'Failed to decrypt message using symmetric encryption despite decryption method being specified', + e + ); + return; + } + case DecryptionMethod.Symmetric: + try { + return await version_1.decryptSymmetric(payload, key); + } catch (e) { + dbg( + 'Failed to decrypt message using asymmetric encryption despite decryption method being specified', + e + ); + return; + } + default: + try { + return await version_1.decryptSymmetric(payload, key); + } catch (e) { + dbg( + 'Failed to decrypt message using symmetric encryption', + e + ); + try { + return await version_1.decryptAsymmetric(payload, key); + } catch (e) { + dbg( + 'Failed to decrypt message using asymmetric encryption', + e + ); + return; + } + } } + } else { + // No key available for this content topic + return; } }) ); diff --git a/src/lib/waku_relay/index.node.spec.ts b/src/lib/waku_relay/index.node.spec.ts index 9689882b3b..7c1a9ba3bd 100644 --- a/src/lib/waku_relay/index.node.spec.ts +++ b/src/lib/waku_relay/index.node.spec.ts @@ -12,7 +12,12 @@ import { } from '../../test_utils'; import { delay } from '../delay'; import { DefaultPubSubTopic, Waku } from '../waku'; -import { WakuMessage } from '../waku_message'; +import { DecryptionMethod, WakuMessage } from '../waku_message'; +import { + generatePrivateKey, + generateSymmetricKey, + getPublicKey, +} from '../waku_message/version_1'; const log = debug('waku:test'); @@ -158,6 +163,72 @@ describe('Waku Relay [node only]', () => { expect(allMessages[1].payloadAsUtf8).to.eq(barMessageText); }); + it('Decrypt messages', async function () { + this.timeout(10000); + + const encryptedAsymmetricMessageText = + 'This message is encrypted using asymmetric'; + const encryptedAsymmetricContentTopic = '/test/1/asymmetric/proto'; + const encryptedSymmetricMessageText = + 'This message is encrypted using symmetric encryption'; + const encryptedSymmetricContentTopic = '/test/1/symmetric/proto'; + + const privateKey = generatePrivateKey(); + const symKey = generateSymmetricKey(); + const publicKey = getPublicKey(privateKey); + + const [encryptedAsymmetricMessage, encryptedSymmetricMessage] = + await Promise.all([ + WakuMessage.fromUtf8String( + encryptedAsymmetricMessageText, + encryptedAsymmetricContentTopic, + { + encPublicKey: publicKey, + } + ), + WakuMessage.fromUtf8String( + encryptedSymmetricMessageText, + encryptedSymmetricContentTopic, + { + symKey: symKey, + } + ), + ]); + + waku2.addDecryptionKey(privateKey, { + contentTopics: [encryptedAsymmetricContentTopic], + method: DecryptionMethod.Asymmetric, + }); + waku2.addDecryptionKey(symKey, { + contentTopics: [encryptedSymmetricContentTopic], + method: DecryptionMethod.Symmetric, + }); + + const msgs: WakuMessage[] = []; + waku2.relay.addObserver((wakuMsg) => { + msgs.push(wakuMsg); + }); + + await waku1.relay.send(encryptedAsymmetricMessage); + await waku1.relay.send(encryptedSymmetricMessage); + + while (msgs.length < 2) { + await delay(200); + } + + expect(msgs.length).to.eq(2); + expect(msgs[0].contentTopic).to.eq( + encryptedAsymmetricMessage.contentTopic + ); + expect(msgs[0].version).to.eq(encryptedAsymmetricMessage.version); + expect(msgs[0].payloadAsUtf8).to.eq(encryptedAsymmetricMessageText); + expect(msgs[1].contentTopic).to.eq( + encryptedSymmetricMessage.contentTopic + ); + expect(msgs[1].version).to.eq(encryptedSymmetricMessage.version); + expect(msgs[1].payloadAsUtf8).to.eq(encryptedSymmetricMessageText); + }); + it('Delete observer', async function () { this.timeout(10000); diff --git a/src/lib/waku_relay/index.ts b/src/lib/waku_relay/index.ts index 557f337421..0b6073752a 100644 --- a/src/lib/waku_relay/index.ts +++ b/src/lib/waku_relay/index.ts @@ -19,7 +19,7 @@ import PeerId from 'peer-id'; import { hexToBuf } from '../utils'; import { CreateOptions, DefaultPubSubTopic } from '../waku'; -import { WakuMessage } from '../waku_message'; +import { DecryptionMethod, WakuMessage } from '../waku_message'; import * as constants from './constants'; import { RelayCodecs } from './constants'; @@ -65,7 +65,10 @@ export class WakuRelay extends Gossipsub { heartbeat: RelayHeartbeat; pubSubTopic: string; - public decryptionKeys: Set; + public decryptionKeys: Map< + Uint8Array, + { method?: DecryptionMethod; contentTopics?: string[] } + >; /** * observers called when receiving new message. @@ -89,13 +92,17 @@ export class WakuRelay extends Gossipsub { this.heartbeat = new RelayHeartbeat(this); this.observers = {}; - this.decryptionKeys = new Set(); + this.decryptionKeys = new Map(); const multicodecs = constants.RelayCodecs; Object.assign(this, { multicodecs }); this.pubSubTopic = options?.pubSubTopic || DefaultPubSubTopic; + + options?.decryptionKeys?.forEach((key) => { + this.addDecryptionKey(key); + }); } /** @@ -128,8 +135,11 @@ export class WakuRelay extends Gossipsub { * * Strings must be in hex format. */ - addDecryptionKey(key: Uint8Array | string): void { - this.decryptionKeys.add(hexToBuf(key)); + addDecryptionKey( + key: Uint8Array | string, + options?: { method?: DecryptionMethod; contentTopics?: string[] } + ): void { + this.decryptionKeys.set(hexToBuf(key), options ?? {}); } /** @@ -210,8 +220,18 @@ export class WakuRelay extends Gossipsub { */ subscribe(pubSubTopic: string): void { this.on(pubSubTopic, (event) => { + const decryptionKeys = Array.from(this.decryptionKeys).map( + ([key, { method, contentTopics }]) => { + return { + key, + method, + contentTopics, + }; + } + ); + dbg(`Message received on ${pubSubTopic}`); - WakuMessage.decode(event.data, Array.from(this.decryptionKeys)) + WakuMessage.decode(event.data, decryptionKeys) .then((wakuMsg) => { if (!wakuMsg) { dbg('Failed to decode Waku Message'); diff --git a/src/lib/waku_store/index.node.spec.ts b/src/lib/waku_store/index.node.spec.ts index 7ec29842d0..30c334a42d 100644 --- a/src/lib/waku_store/index.node.spec.ts +++ b/src/lib/waku_store/index.node.spec.ts @@ -12,7 +12,7 @@ import { } from '../../test_utils'; import { delay } from '../delay'; import { Waku } from '../waku'; -import { WakuMessage } from '../waku_message'; +import { DecryptionMethod, WakuMessage } from '../waku_message'; import { generatePrivateKey, generateSymmetricKey, @@ -335,6 +335,122 @@ describe('Waku Store', () => { await Promise.all([waku1.stop(), waku2.stop()]); }); + it('Retrieves history with asymmetric & symmetric encrypted messages on different content topics', async function () { + this.timeout(10_000); + + nimWaku = new NimWaku(makeLogFileName(this)); + await nimWaku.start({ persistMessages: true, lightpush: true }); + + const encryptedAsymmetricMessageText = + 'This message is encrypted for me using asymmetric'; + const encryptedAsymmetricContentTopic = '/test/1/asymmetric/proto'; + const encryptedSymmetricMessageText = + 'This message is encrypted for me using symmetric encryption'; + const encryptedSymmetricContentTopic = '/test/1/symmetric/proto'; + const clearMessageText = + 'This is a clear text message for everyone to read'; + const otherEncMessageText = + 'This message is not for and I must not be able to read it'; + + const privateKey = generatePrivateKey(); + const symKey = generateSymmetricKey(); + const publicKey = getPublicKey(privateKey); + + const [ + encryptedAsymmetricMessage, + encryptedSymmetricMessage, + clearMessage, + otherEncMessage, + ] = await Promise.all([ + WakuMessage.fromUtf8String( + encryptedAsymmetricMessageText, + encryptedAsymmetricContentTopic, + { + encPublicKey: publicKey, + } + ), + WakuMessage.fromUtf8String( + encryptedSymmetricMessageText, + encryptedSymmetricContentTopic, + { + symKey: symKey, + } + ), + WakuMessage.fromUtf8String( + clearMessageText, + encryptedAsymmetricContentTopic + ), + WakuMessage.fromUtf8String( + otherEncMessageText, + encryptedSymmetricContentTopic, + { + encPublicKey: getPublicKey(generatePrivateKey()), + } + ), + ]); + + dbg('Messages have been encrypted'); + + const [waku1, waku2, nimWakuMultiaddr] = await Promise.all([ + Waku.create({ + staticNoiseKey: NOISE_KEY_1, + libp2p: { modules: { transport: [TCP] } }, + }), + Waku.create({ + staticNoiseKey: NOISE_KEY_2, + libp2p: { modules: { transport: [TCP] } }, + }), + nimWaku.getMultiaddrWithId(), + ]); + + dbg('Waku nodes created'); + + await Promise.all([ + waku1.dial(nimWakuMultiaddr), + waku2.dial(nimWakuMultiaddr), + ]); + + dbg('Waku nodes connected to nim Waku'); + + let lightPushPeers = waku1.lightPush.peers; + while (lightPushPeers.length == 0) { + await delay(100); + lightPushPeers = waku1.lightPush.peers; + } + + dbg('Sending messages using light push'); + await Promise.all([ + waku1.lightPush.push(encryptedAsymmetricMessage), + waku1.lightPush.push(encryptedSymmetricMessage), + waku1.lightPush.push(otherEncMessage), + waku1.lightPush.push(clearMessage), + ]); + + let storePeers = waku2.store.peers; + while (storePeers.length == 0) { + await delay(100); + storePeers = waku2.store.peers; + } + + waku2.addDecryptionKey(symKey, { + contentTopics: [encryptedSymmetricContentTopic], + method: DecryptionMethod.Symmetric, + }); + + dbg('Retrieve messages from store'); + const messages = await waku2.store.queryHistory([], { + decryptionKeys: [privateKey], + }); + + expect(messages?.length).eq(3); + if (!messages) throw 'Length was tested'; + expect(messages[0].payloadAsUtf8).to.eq(clearMessageText); + expect(messages[1].payloadAsUtf8).to.eq(encryptedSymmetricMessageText); + expect(messages[2].payloadAsUtf8).to.eq(encryptedAsymmetricMessageText); + + await Promise.all([waku1.stop(), waku2.stop()]); + }); + it('Retrieves history using start and end time', async function () { this.timeout(5_000); diff --git a/src/lib/waku_store/index.ts b/src/lib/waku_store/index.ts index 1607a74fb7..ee98e1fdaf 100644 --- a/src/lib/waku_store/index.ts +++ b/src/lib/waku_store/index.ts @@ -10,7 +10,7 @@ import { HistoryResponse_Error } from '../../proto'; import { getPeersForProtocol, selectRandomPeer } from '../select_peer'; import { hexToBuf } from '../utils'; import { DefaultPubSubTopic } from '../waku'; -import { WakuMessage } from '../waku_message'; +import { DecryptionMethod, WakuMessage } from '../waku_message'; import { HistoryRPC, PageDirection } from './history_rpc'; @@ -96,7 +96,10 @@ export interface QueryOptions { */ export class WakuStore { pubSubTopic: string; - public decryptionKeys: Set; + public decryptionKeys: Map< + Uint8Array, + { method?: DecryptionMethod; contentTopics?: string[] } + >; constructor(public libp2p: Libp2p, options?: CreateOptions) { if (options?.pubSubTopic) { @@ -105,7 +108,7 @@ export class WakuStore { this.pubSubTopic = DefaultPubSubTopic; } - this.decryptionKeys = new Set(); + this.decryptionKeys = new Map(); } /** @@ -157,10 +160,25 @@ export class WakuStore { const connection = this.libp2p.connectionManager.get(peer.id); if (!connection) throw 'Failed to get a connection to the peer'; - const decryptionKeys = Array.from(this.decryptionKeys.values()); + const decryptionKeys = Array.from(this.decryptionKeys).map( + ([key, { method, contentTopics }]) => { + return { + key, + method, + contentTopics, + }; + } + ); + + // Add the decryption keys passed to this function against the + // content topics also passed to this function. if (opts.decryptionKeys) { opts.decryptionKeys.forEach((key) => { - decryptionKeys.push(hexToBuf(key)); + decryptionKeys.push({ + key: hexToBuf(key), + contentTopics: contentTopics.length ? contentTopics : undefined, + method: undefined, + }); }); } @@ -248,8 +266,11 @@ export class WakuStore { * * Strings must be in hex format. */ - addDecryptionKey(key: Uint8Array | string): void { - this.decryptionKeys.add(hexToBuf(key)); + addDecryptionKey( + key: Uint8Array | string, + options?: { method?: DecryptionMethod; contentTopics?: string[] } + ): void { + this.decryptionKeys.set(hexToBuf(key), options ?? {}); } /**