From d71b473ff71f193afee6e0fc3c07aee941efff3b Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 23 Apr 2024 12:49:25 -0600 Subject: [PATCH 1/8] feat: consent proofs Updated Protos Added ability to add consent proofs to conversations Updated types Added consentProof on Invitiation class --- bench/decode.ts | 2 ++ bench/encode.ts | 2 ++ package.json | 2 +- src/Invitation.ts | 7 +++- src/Message.ts | 4 ++- src/conversations/Conversation.ts | 15 ++++++++- src/conversations/Conversations.ts | 32 ++++++++++++++---- src/crypto/Signature.ts | 14 ++++++++ src/keystore/InMemoryKeystore.ts | 2 ++ src/keystore/utils.ts | 1 + test/Invitation.test.ts | 4 +++ test/conversations/Conversations.test.ts | 41 ++++++++++++++++++++++-- test/keystore/InMemoryKeystore.test.ts | 14 ++++++++ test/keystore/conversationStores.test.ts | 1 + yarn.lock | 10 +++--- 15 files changed, 134 insertions(+), 17 deletions(-) diff --git a/bench/decode.ts b/bench/decode.ts index 4cda9623..ab3c84f1 100644 --- a/bench/decode.ts +++ b/bench/decode.ts @@ -65,12 +65,14 @@ const decodeV2 = () => { ), createdNs: dateToNs(new Date()), context: undefined, + consentProof: undefined, }) const convo = new ConversationV2( alice, invite.conversation?.topic ?? '', bob.identityKey.publicKey.walletSignatureAddress(), new Date(), + undefined, undefined ) const { payload, shouldPush } = await alice.encodeContent(message) diff --git a/bench/encode.ts b/bench/encode.ts index 3b1da38d..8691e5af 100644 --- a/bench/encode.ts +++ b/bench/encode.ts @@ -49,12 +49,14 @@ const encodeV2 = () => { ), createdNs: dateToNs(new Date()), context: undefined, + consentProof: undefined, }) const convo = new ConversationV2( alice, invite.conversation?.topic ?? '', bob.identityKey.publicKey.walletSignatureAddress(), new Date(), + undefined, undefined ) const message = randomBytes(size) diff --git a/package.json b/package.json index d611be20..f678a50c 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ }, "dependencies": { "@noble/secp256k1": "1.7.1", - "@xmtp/proto": "3.45.0", + "@xmtp/proto": "3.54.0", "@xmtp/user-preferences-bindings-wasm": "^0.3.6", "async-mutex": "^0.5.0", "elliptic": "^6.5.4", diff --git a/src/Invitation.ts b/src/Invitation.ts index c2889c3b..a0ea54d4 100644 --- a/src/Invitation.ts +++ b/src/Invitation.ts @@ -20,6 +20,7 @@ export class InvitationV1 implements invitation.InvitationV1 { topic: string context: InvitationContext | undefined aes256GcmHkdfSha256: invitation.InvitationV1_Aes256gcmHkdfsha256 // eslint-disable-line camelcase + consentProof: invitation.ConsentProofPayload | undefined constructor({ topic, @@ -41,7 +42,10 @@ export class InvitationV1 implements invitation.InvitationV1 { this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256 } - static createRandom(context?: invitation.InvitationV1_Context): InvitationV1 { + static createRandom( + context?: invitation.InvitationV1_Context, + consentProof?: invitation.ConsentProofPayload + ): InvitationV1 { const topic = buildDirectMessageTopicV2( Buffer.from(crypto.getRandomValues(new Uint8Array(32))) .toString('base64') @@ -56,6 +60,7 @@ export class InvitationV1 implements invitation.InvitationV1 { topic, aes256GcmHkdfSha256: { keyMaterial }, context, + consentProof, }) } diff --git a/src/Message.ts b/src/Message.ts index 7ee3e2b2..e5179bbd 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -279,6 +279,7 @@ export class DecodedMessage { context: this.conversation.context ?? undefined, createdNs: dateToNs(this.conversation.createdAt), peerAddress: this.conversation.peerAddress, + consentProofPayload: this.conversation.consentProof ?? undefined, }, sentNs: dateToNs(this.sent), }).finish() @@ -395,7 +396,8 @@ function conversationReferenceToConversation( reference.topic, reference.peerAddress, nsToDate(reference.createdNs), - reference.context + reference.context, + reference.consentProofPayload ) } throw new Error(`Unknown conversation version ${version}`) diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 38f3a6b4..157280d2 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -4,6 +4,7 @@ import { type keystore, type messageApi, } from '@xmtp/proto' +import type { ConsentProofPayload } from '@xmtp/proto/ts/dist/types/message_contents/invitation.pb' import { getAddress } from 'viem' import type { OnConnectionLostCallback } from '@/ApiClient' import type { @@ -85,6 +86,11 @@ export interface Conversation { */ consentState: ConsentState + /** + * Proof of consent for the conversation, used when a user has pre-consented to a conversation + */ + consentProof?: ConsentProofPayload + /** * Retrieve messages in this conversation. Default to returning all messages. * @@ -502,19 +508,22 @@ export class ConversationV2 peerAddress: string createdAt: Date context?: InvitationContext + consentProof?: ConsentProofPayload constructor( client: Client, topic: string, peerAddress: string, createdAt: Date, - context: InvitationContext | undefined + context: InvitationContext | undefined, + consentProof: ConsentProofPayload | undefined ) { this.topic = topic this.createdAt = createdAt this.context = context this.client = client this.peerAddress = peerAddress + this.consentProof = consentProof } get clientAddress() { @@ -541,6 +550,10 @@ export class ConversationV2 return this.client.contacts.consentState(this.peerAddress) } + get consentProofPayload(): ConsentProofPayload | undefined { + return this.consentProof + } + /** * Returns a list of all messages to/from the peerAddress */ diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index b5ff3f06..6d91335c 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -1,4 +1,9 @@ -import type { conversationReference, keystore, messageApi } from '@xmtp/proto' +import type { + conversationReference, + invitation, + keystore, + messageApi, +} from '@xmtp/proto' import Long from 'long' import { SortDirection, type OnConnectionLostCallback } from '@/ApiClient' import type { ListMessagesOptions } from '@/Client' @@ -27,7 +32,6 @@ import JobRunner from './JobRunner' const messageHasHeaders = (msg: MessageV1): boolean => { return Boolean(msg.recipientAddress && msg.senderAddress) } - /** * Conversations allows you to view ongoing 1:1 messaging sessions with another wallet */ @@ -88,6 +92,7 @@ export default class Conversations { createdNs: dateToNs(createdAt), topic: buildDirectMessageTopic(peerAddress, this.client.address), context: undefined, + consentProofPayload: undefined, })) .filter((c) => isValidTopic(c.topic)), }) @@ -169,6 +174,13 @@ export default class Conversations { const out: ConversationV2[] = [] for (const response of responses) { try { + console.log( + 'here11113', + !!response.result?.conversation?.consentProofPayload + ) + // if (response.result?.conversation?.consentProofPayload) { + + // } out.push(this.saveInviteResponseToConversation(response)) } catch (e) { console.warn('Error saving invite response to conversation: ', e) @@ -198,7 +210,8 @@ export default class Conversations { convoRef.topic, convoRef.peerAddress, nsToDate(convoRef.createdNs), - convoRef.context + convoRef.context, + convoRef.consentProofPayload ) } @@ -469,7 +482,8 @@ export default class Conversations { */ async newConversation( peerAddress: string, - context?: InvitationContext + context?: InvitationContext, + consentProof?: invitation.ConsentProofPayload ): Promise> { let contact = await this.client.getUserContact(peerAddress) if (!contact) { @@ -535,19 +549,25 @@ export default class Conversations { return newItemMatch } - return this.createV2Convo(contact as SignedPublicKeyBundle, context) + return this.createV2Convo( + contact as SignedPublicKeyBundle, + context, + consentProof + ) }) } private async createV2Convo( recipient: SignedPublicKeyBundle, - context?: InvitationContext + context?: InvitationContext, + consentProof?: invitation.ConsentProofPayload ): Promise> { const timestamp = new Date() const { payload, conversation } = await this.client.keystore.createInvite({ recipient, context, createdNs: dateToNs(timestamp), + consentProof, }) if (!payload || !conversation) { throw new Error('Required field not returned from Keystore') diff --git a/src/crypto/Signature.ts b/src/crypto/Signature.ts index f83601df..ee21454f 100644 --- a/src/crypto/Signature.ts +++ b/src/crypto/Signature.ts @@ -159,6 +159,20 @@ export class WalletSigner implements KeySigner { ) } + static consentProofRequestText( + peerAddress: string, + timestamp: number + ): string { + return ( + 'XMTP : Grant inbox consent to sender\n' + + '\n' + + `Current Time: ${timestamp}\n` + + `From Address: ${peerAddress}\n` + + '\n' + + 'For more info: https://xmtp.org/signatures/' + ) + } + static signerKey( key: SignedPublicKey, signature: ECDSACompactWithRecovery diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 3590031d..85b4e2e5 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -421,6 +421,7 @@ export default class InMemoryKeystore implements KeystoreInterface { topic: buildDirectMessageTopicV2(topic), aes256GcmHkdfSha256: { keyMaterial }, context: req.context, + consentProof: req.consentProof, }) const sealed = await SealedInvitation.createV1({ @@ -575,6 +576,7 @@ export default class InMemoryKeystore implements KeystoreInterface { createdNs: data.createdNs, topic: buildDirectMessageTopic(data.peerAddress, this.walletAddress), context: undefined, + consentProofPayload: undefined, } } diff --git a/src/keystore/utils.ts b/src/keystore/utils.ts index 42d20462..21087bcb 100644 --- a/src/keystore/utils.ts +++ b/src/keystore/utils.ts @@ -118,6 +118,7 @@ export const topicDataToV2ConversationReference = ({ topic: invitation.topic, peerAddress, createdNs, + consentProofPayload: invitation.consentProof, }) export const isCompleteTopicData = ( diff --git a/test/Invitation.test.ts b/test/Invitation.test.ts index 780f617b..a46633ff 100644 --- a/test/Invitation.test.ts +++ b/test/Invitation.test.ts @@ -18,6 +18,7 @@ const createInvitation = (): InvitationV1 => { aes256GcmHkdfSha256: { keyMaterial: crypto.getRandomValues(new Uint8Array(32)), }, + consentProof: undefined, }) } @@ -192,6 +193,7 @@ describe('Invitations', () => { aes256GcmHkdfSha256: { keyMaterial, }, + consentProof: undefined, }) expect(invite.topic).toEqual(topic) @@ -213,6 +215,7 @@ describe('Invitations', () => { topic, context: undefined, aes256GcmHkdfSha256: { keyMaterial: new Uint8Array() }, + consentProof: undefined, }) ).toThrow('Missing key material') @@ -222,6 +225,7 @@ describe('Invitations', () => { topic: '', context: undefined, aes256GcmHkdfSha256: { keyMaterial }, + consentProof: undefined, }) ).toThrow('Missing topic') }) diff --git a/test/conversations/Conversations.test.ts b/test/conversations/Conversations.test.ts index 45a80883..878b37ac 100644 --- a/test/conversations/Conversations.test.ts +++ b/test/conversations/Conversations.test.ts @@ -1,8 +1,12 @@ -import type Client from '@/Client' +import { invitation } from '@xmtp/proto' +import Client from '@/Client' import { ConversationV1, ConversationV2 } from '@/conversations/Conversation' +// import { PrivateKey, SignedPrivateKey } from '@/crypto/PrivateKey' +// import { PublicKey, SignedPublicKey } from '@/crypto/PublicKey' +import { WalletSigner } from '@/crypto/Signature' import { sleep } from '@/utils/async' import { buildDirectMessageTopic, buildUserIntroTopic } from '@/utils/topic' -import { newLocalHostClient } from '@test/helpers' +import { newLocalHostClient, newWallet } from '@test/helpers' describe('conversations', () => { describe('listConversations', () => { @@ -280,6 +284,39 @@ describe('conversations', () => { const invites = await alice.listInvitations() expect(invites).toHaveLength(1) }) + + it('handles consent proof on invitation', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() + const timestamp = Date.now() + const consentMessage = WalletSigner.consentProofRequestText( + bob.address, + timestamp + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) + const alix = await Client.create(wallet, { + env: 'local', + }) + const conversations = await alix.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + await alix.contacts.refreshConsentList() + const isApproved = await alix.contacts.isAllowed(bob.address) + expect(isApproved).toBeTruthy() + await alix.close() + await bo.close() + }) }) }) diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index c5ce0f6d..2a26bfe4 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -193,6 +193,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs, context: undefined, + consentProof: undefined, }) expect(response.conversation?.topic).toBeTruthy() @@ -211,6 +212,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs, context, + consentProof: undefined, }) expect(response.conversation?.topic).toBeTruthy() @@ -224,6 +226,7 @@ describe('InMemoryKeystore', () => { recipient: {} as any, createdNs, context: undefined, + consentProof: undefined, }) }).rejects.toThrow(KeystoreError) }) @@ -365,6 +368,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs, context: undefined, + consentProof: undefined, }) const payload = new TextEncoder().encode('Hello, world!') @@ -416,6 +420,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs, context: undefined, + consentProof: undefined, }) const payload = new TextEncoder().encode('Hello, world!') @@ -532,6 +537,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs: dateToNs(createdAt), context: undefined, + consentProof: undefined, }) }) ) @@ -565,6 +571,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs: dateToNs(createdAt), context: undefined, + consentProof: undefined, }) responses.push(response) @@ -643,6 +650,7 @@ describe('InMemoryKeystore', () => { conversationId: 'test', metadata: {}, }, + consentProof: undefined, }) expect(aliceInvite.conversation!.topic).toEqual( '/xmtp/0/m-4b52be1e8567d72d0bc407debe2d3c7fca2ae93a47e58c3f9b5c5068aff80ec5/proto' @@ -657,6 +665,7 @@ describe('InMemoryKeystore', () => { conversationId: 'test', metadata: {}, }, + consentProof: undefined, }) expect(bobInvite.conversation!.topic).toEqual( '/xmtp/0/m-4b52be1e8567d72d0bc407debe2d3c7fca2ae93a47e58c3f9b5c5068aff80ec5/proto' @@ -686,6 +695,7 @@ describe('InMemoryKeystore', () => { conversationId: 'test', metadata: {}, }, + consentProof: undefined, }) responses.push(response) @@ -711,6 +721,7 @@ describe('InMemoryKeystore', () => { ), createdNs: dateToNs(new Date()), context: undefined, + consentProof: undefined, }) const bobInvite = await bobKeystore.createInvite({ recipient: SignedPublicKeyBundle.fromLegacyBundle( @@ -718,6 +729,7 @@ describe('InMemoryKeystore', () => { ), createdNs: dateToNs(new Date()), context: undefined, + consentProof: undefined, }) expect( await aliceKeys.sharedSecret( @@ -891,6 +903,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs: dateToNs(createdAt), context: undefined, + consentProof: undefined, }) }) ) @@ -991,6 +1004,7 @@ describe('InMemoryKeystore', () => { recipient, createdNs: dateToNs(createdAt), context: undefined, + consentProof: undefined, }) }) ) diff --git a/test/keystore/conversationStores.test.ts b/test/keystore/conversationStores.test.ts index 7e76e530..4ab3a2d1 100644 --- a/test/keystore/conversationStores.test.ts +++ b/test/keystore/conversationStores.test.ts @@ -22,6 +22,7 @@ const buildAddRequest = (): AddRequest => { conversationId: 'foo', metadata: {}, }, + consentProof: undefined, }, } } diff --git a/yarn.lock b/yarn.lock index cdd9f89a..a272794b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3154,15 +3154,15 @@ __metadata: languageName: node linkType: hard -"@xmtp/proto@npm:3.45.0": - version: 3.45.0 - resolution: "@xmtp/proto@npm:3.45.0" +"@xmtp/proto@npm:3.54.0": + version: 3.54.0 + resolution: "@xmtp/proto@npm:3.54.0" dependencies: long: "npm:^5.2.0" protobufjs: "npm:^7.0.0" rxjs: "npm:^7.8.0" undici: "npm:^5.8.1" - checksum: 10/8f9cb9de2265db8c329edbb6e17af117fd7a2ad6a688b3a353e103ce609d57f2df682aae481e6a4aa9df07fe7135cac8ba6e5a91f69a7d7a9f4946b2083e3a02 + checksum: 10/536b846b234bdf49978716b232b6a25985a55bb0c10c825b090354cb43f0b071ee506e5a9765c69deb0e6a4b9aa4cd61abe2c9d2df563cfd6ec121197b591844 languageName: node linkType: hard @@ -3206,7 +3206,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^7.2.0" "@typescript-eslint/parser": "npm:^7.2.0" "@vitest/coverage-v8": "npm:^1.3.1" - "@xmtp/proto": "npm:3.45.0" + "@xmtp/proto": "npm:3.54.0" "@xmtp/rollup-plugin-resolve-extensions": "npm:1.0.1" "@xmtp/user-preferences-bindings-wasm": "npm:^0.3.6" async-mutex: "npm:^0.5.0" From 22ce2f84361a402e454cec80c67b44bc105ede38 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 23 Apr 2024 18:29:42 -0600 Subject: [PATCH 2/8] feat: verify consent proof signature Verified consent proofs Added tests for consent proofs --- src/Invitation.ts | 2 + src/conversations/Conversations.ts | 55 +++++++++++++--- test/conversations/Conversations.test.ts | 81 ++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/src/Invitation.ts b/src/Invitation.ts index a0ea54d4..3a38bac4 100644 --- a/src/Invitation.ts +++ b/src/Invitation.ts @@ -26,6 +26,7 @@ export class InvitationV1 implements invitation.InvitationV1 { topic, context, aes256GcmHkdfSha256, + consentProof, }: invitation.InvitationV1) { if (!topic || !topic.length) { throw new Error('Missing topic') @@ -40,6 +41,7 @@ export class InvitationV1 implements invitation.InvitationV1 { this.topic = topic this.context = context this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256 + this.consentProof = consentProof } static createRandom( diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index 6d91335c..a40a6ec1 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -5,6 +5,7 @@ import type { messageApi, } from '@xmtp/proto' import Long from 'long' +import { hashMessage, hexToBytes } from 'viem' import { SortDirection, type OnConnectionLostCallback } from '@/ApiClient' import type { ListMessagesOptions } from '@/Client' import type Client from '@/Client' @@ -12,6 +13,8 @@ import { PublicKeyBundle, SignedPublicKeyBundle, } from '@/crypto/PublicKeyBundle' +import { ecdsaSignerKey, WalletSigner } from '@/crypto/Signature' +import { splitSignature } from '@/crypto/utils' import type { InvitationContext } from '@/Invitation' import { DecodedMessage, MessageV1 } from '@/Message' import Stream from '@/Stream' @@ -157,6 +160,43 @@ export default class Conversations { return this.decodeInvites(envelopes) } + private async validateConsentSignature( + signature: `0x${string}`, + timestamp: number, + peerAddress: string + ): Promise { + const signatureData = splitSignature(signature) + const message = WalletSigner.consentProofRequestText(peerAddress, timestamp) + const digest = hexToBytes(hashMessage(message)) + // Recover public key + const publicKey = ecdsaSignerKey(digest, signatureData) + if (!publicKey) { + return false + } + console.log('Recovered public key: ', typeof publicKey) + console.log('here1116', publicKey.getEthereumAddress()) + return publicKey.getEthereumAddress() === this.client.address + } + + private async handleConsentProof( + consentProof: invitation.ConsentProofPayload, + peerAddress: string + ): Promise { + const { signature, timestamp } = consentProof + const isValid = await this.validateConsentSignature( + signature as `0x${string}`, + Number(timestamp), + peerAddress + ) + if (!isValid) { + return + } + const consentState = await this.client.contacts.consentState(peerAddress) + if (consentState === 'unknown') { + this.client.contacts.allow([peerAddress]) + } + } + private async decodeInvites( envelopes: messageApi.Envelope[], shouldThrow = false @@ -174,13 +214,12 @@ export default class Conversations { const out: ConversationV2[] = [] for (const response of responses) { try { - console.log( - 'here11113', - !!response.result?.conversation?.consentProofPayload - ) - // if (response.result?.conversation?.consentProofPayload) { - - // } + if (response.result?.conversation?.consentProofPayload) { + this.handleConsentProof( + response.result.conversation.consentProofPayload, + response.result.conversation.peerAddress + ) + } out.push(this.saveInviteResponseToConversation(response)) } catch (e) { console.warn('Error saving invite response to conversation: ', e) @@ -498,7 +537,6 @@ export default class Conversations { if (contact instanceof PublicKeyBundle && !context?.conversationId) { return new ConversationV1(this.client, peerAddress, new Date()) } - // If no conversationId, check and see if we have an existing V1 conversation if (!context?.conversationId) { const v1Convos = await this.listV1Conversations() @@ -548,7 +586,6 @@ export default class Conversations { if (newItemMatch) { return newItemMatch } - return this.createV2Convo( contact as SignedPublicKeyBundle, context, diff --git a/test/conversations/Conversations.test.ts b/test/conversations/Conversations.test.ts index 878b37ac..42bd077c 100644 --- a/test/conversations/Conversations.test.ts +++ b/test/conversations/Conversations.test.ts @@ -1,8 +1,6 @@ import { invitation } from '@xmtp/proto' import Client from '@/Client' import { ConversationV1, ConversationV2 } from '@/conversations/Conversation' -// import { PrivateKey, SignedPrivateKey } from '@/crypto/PrivateKey' -// import { PublicKey, SignedPublicKey } from '@/crypto/PublicKey' import { WalletSigner } from '@/crypto/Signature' import { sleep } from '@/utils/async' import { buildDirectMessageTopic, buildUserIntroTopic } from '@/utils/topic' @@ -290,30 +288,103 @@ describe('conversations', () => { const wallet = newWallet() const keySigner = new WalletSigner(wallet) const alixAddress = await keySigner.wallet.getAddress() + const alix = await Client.create(wallet, { + env: 'local', + }) const timestamp = Date.now() const consentMessage = WalletSigner.consentProofRequestText( - bob.address, + bo.address, + timestamp + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) + const conversations = await alix.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + await alix.contacts.refreshConsentList() + const isApproved = await alix.contacts.isAllowed(bo.address) + expect(isApproved).toBeTruthy() + await alix.close() + await bo.close() + }) + + it('consent proof yields to network consent', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() + const alix = await Client.create(wallet, { + env: 'local', + }) + const timestamp = Date.now() + const consentMessage = WalletSigner.consentProofRequestText( + bo.address, timestamp ) const signedMessage = await keySigner.wallet.signMessage(consentMessage) const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ signature: signedMessage, timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, }) const boConvo = await bo.conversations.newConversation( alixAddress, undefined, consentProofPayload ) + await alix.contacts.deny([bo.address]) + const conversations = await alix.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + await alix.contacts.refreshConsentList() + const isDenied = await alix.contacts.isDenied(bo.address) + expect(isDenied).toBeTruthy() + await alix.close() + await bo.close() + }) + + it('consent proof correctly validates', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() const alix = await Client.create(wallet, { env: 'local', }) + const timestamp = Date.now() + 1 + const consentMessage = WalletSigner.consentProofRequestText( + bo.address, + timestamp + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) const conversations = await alix.conversations.list() const convo = conversations.find((c) => c.topic === boConvo.topic) expect(convo).toBeTruthy() await alix.contacts.refreshConsentList() - const isApproved = await alix.contacts.isAllowed(bob.address) - expect(isApproved).toBeTruthy() + const isAllowed = await alix.contacts.isAllowed(bo.address) + expect(isAllowed).toBeFalsy() await alix.close() await bo.close() }) From 7cfb79de6824a574a9144fc706fdd4463a34f379 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 23 Apr 2024 18:44:50 -0600 Subject: [PATCH 3/8] feat: verify consent proof signature Verified consent proofs Added tests for consent proofs --- test/conversations/Conversations.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/conversations/Conversations.test.ts b/test/conversations/Conversations.test.ts index 42bd077c..1442b650 100644 --- a/test/conversations/Conversations.test.ts +++ b/test/conversations/Conversations.test.ts @@ -362,10 +362,10 @@ describe('conversations', () => { const alix = await Client.create(wallet, { env: 'local', }) - const timestamp = Date.now() + 1 + const timestamp = Date.now() const consentMessage = WalletSigner.consentProofRequestText( bo.address, - timestamp + timestamp + 1 ) const signedMessage = await keySigner.wallet.signMessage(consentMessage) const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ From acf204bad82689ed06839b85c5dfb92eb6997bf2 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 23 Apr 2024 18:48:58 -0600 Subject: [PATCH 4/8] feat: verify consent proof signature Verified consent proofs Added tests for consent proofs --- src/conversations/Conversations.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index a40a6ec1..1c3b9165 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -170,12 +170,7 @@ export default class Conversations { const digest = hexToBytes(hashMessage(message)) // Recover public key const publicKey = ecdsaSignerKey(digest, signatureData) - if (!publicKey) { - return false - } - console.log('Recovered public key: ', typeof publicKey) - console.log('here1116', publicKey.getEthereumAddress()) - return publicKey.getEthereumAddress() === this.client.address + return publicKey?.getEthereumAddress() === this.client.address } private async handleConsentProof( From 854779ec244274cffe04182d5fa8b1fcb74096e7 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 30 Apr 2024 16:13:04 -0600 Subject: [PATCH 5/8] feat: consent proof updates Updated consent proof handling to be on the updateV2Conversations method instead --- src/conversations/Conversation.ts | 10 +++++----- src/conversations/Conversations.ts | 22 ++++++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 157280d2..340c3f54 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -1,10 +1,10 @@ import { message, content as proto, + type invitation, type keystore, type messageApi, } from '@xmtp/proto' -import type { ConsentProofPayload } from '@xmtp/proto/ts/dist/types/message_contents/invitation.pb' import { getAddress } from 'viem' import type { OnConnectionLostCallback } from '@/ApiClient' import type { @@ -89,7 +89,7 @@ export interface Conversation { /** * Proof of consent for the conversation, used when a user has pre-consented to a conversation */ - consentProof?: ConsentProofPayload + consentProof?: invitation.ConsentProofPayload /** * Retrieve messages in this conversation. Default to returning all messages. @@ -508,7 +508,7 @@ export class ConversationV2 peerAddress: string createdAt: Date context?: InvitationContext - consentProof?: ConsentProofPayload + consentProof?: invitation.ConsentProofPayload constructor( client: Client, @@ -516,7 +516,7 @@ export class ConversationV2 peerAddress: string, createdAt: Date, context: InvitationContext | undefined, - consentProof: ConsentProofPayload | undefined + consentProof: invitation.ConsentProofPayload | undefined ) { this.topic = topic this.createdAt = createdAt @@ -550,7 +550,7 @@ export class ConversationV2 return this.client.contacts.consentState(this.peerAddress) } - get consentProofPayload(): ConsentProofPayload | undefined { + get consentProofPayload(): invitation.ConsentProofPayload | undefined { return this.consentProof } diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index 1c3b9165..b2cecf9b 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -156,17 +156,25 @@ export default class Conversations { startTime, direction: SortDirection.SORT_DIRECTION_ASCENDING, }) - - return this.decodeInvites(envelopes) + const newConversations = await this.decodeInvites(envelopes) + newConversations.forEach((convo) => { + if (convo.consentProofPayload) { + this.handleConsentProof(convo.consentProofPayload, convo.peerAddress) + } + }) + return newConversations } private async validateConsentSignature( signature: `0x${string}`, - timestamp: number, + timestampMs: number, peerAddress: string ): Promise { const signatureData = splitSignature(signature) - const message = WalletSigner.consentProofRequestText(peerAddress, timestamp) + const message = WalletSigner.consentProofRequestText( + peerAddress, + timestampMs + ) const digest = hexToBytes(hashMessage(message)) // Recover public key const publicKey = ecdsaSignerKey(digest, signatureData) @@ -209,12 +217,6 @@ export default class Conversations { const out: ConversationV2[] = [] for (const response of responses) { try { - if (response.result?.conversation?.consentProofPayload) { - this.handleConsentProof( - response.result.conversation.consentProofPayload, - response.result.conversation.peerAddress - ) - } out.push(this.saveInviteResponseToConversation(response)) } catch (e) { console.warn('Error saving invite response to conversation: ', e) From 99b08eaa30c7688214506baca585fc726059bfc4 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 30 Apr 2024 18:22:47 -0600 Subject: [PATCH 6/8] feat: moved consent proof handling to consent list load instead Updated consent list to handle consent proofs --- src/Contacts.ts | 57 ++++++++++- src/conversations/Conversations.ts | 46 +-------- test/Contacts.test.ts | 116 ++++++++++++++++++++++- test/conversations/Conversations.test.ts | 112 +--------------------- 4 files changed, 173 insertions(+), 158 deletions(-) diff --git a/src/Contacts.ts b/src/Contacts.ts index ed8b3e45..2f6140c5 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -1,4 +1,7 @@ -import { privatePreferences } from '@xmtp/proto' +import { privatePreferences, type invitation } from '@xmtp/proto' +import { hashMessage, hexToBytes } from 'viem' +import { ecdsaSignerKey, WalletSigner } from '@/crypto/Signature' +import { splitSignature } from '@/crypto/utils' import type { EnvelopeWithMessage } from '@/utils/async' import { fromNanoString } from '@/utils/date' import { buildUserPrivatePreferencesTopic } from '@/utils/topic' @@ -252,10 +255,60 @@ export class Contacts { this.jobRunner = new JobRunner('user-preferences', client.keystore) } + private async validateConsentSignature( + signature: `0x${string}`, + timestampMs: number, + peerAddress: string + ): Promise { + const signatureData = splitSignature(signature) + const message = WalletSigner.consentProofRequestText( + peerAddress, + timestampMs + ) + const digest = hexToBytes(hashMessage(message)) + // Recover public key + const publicKey = ecdsaSignerKey(digest, signatureData) + return publicKey?.getEthereumAddress() === this.client.address + } + + private async handleConsentProof( + consentProof: invitation.ConsentProofPayload, + peerAddress: string + ): Promise { + const { signature, timestamp } = consentProof + const isValid = await this.validateConsentSignature( + signature as `0x${string}`, + Number(timestamp), + peerAddress + ) + if (!isValid) { + return + } + await this.client.contacts.allow([peerAddress]) + } + async loadConsentList(startTime?: Date) { return this.jobRunner.run(async (lastRun) => { // allow for override of startTime - return this.consentList.load(startTime ?? lastRun) + const entries = await this.consentList.load(startTime ?? lastRun) + try { + const conversations = await this.client.conversations.list() + conversations.forEach((conversation) => { + if ( + conversation.consentProof && + this.consentState(conversation.peerAddress) === 'unknown' + ) { + this.handleConsentProof( + conversation.consentProof, + conversation.peerAddress + ) + } + }) + } catch (err) { + console.log(err) + } + + return entries }) } diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index b2cecf9b..96205057 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -5,7 +5,6 @@ import type { messageApi, } from '@xmtp/proto' import Long from 'long' -import { hashMessage, hexToBytes } from 'viem' import { SortDirection, type OnConnectionLostCallback } from '@/ApiClient' import type { ListMessagesOptions } from '@/Client' import type Client from '@/Client' @@ -13,8 +12,6 @@ import { PublicKeyBundle, SignedPublicKeyBundle, } from '@/crypto/PublicKeyBundle' -import { ecdsaSignerKey, WalletSigner } from '@/crypto/Signature' -import { splitSignature } from '@/crypto/utils' import type { InvitationContext } from '@/Invitation' import { DecodedMessage, MessageV1 } from '@/Message' import Stream from '@/Stream' @@ -156,48 +153,7 @@ export default class Conversations { startTime, direction: SortDirection.SORT_DIRECTION_ASCENDING, }) - const newConversations = await this.decodeInvites(envelopes) - newConversations.forEach((convo) => { - if (convo.consentProofPayload) { - this.handleConsentProof(convo.consentProofPayload, convo.peerAddress) - } - }) - return newConversations - } - - private async validateConsentSignature( - signature: `0x${string}`, - timestampMs: number, - peerAddress: string - ): Promise { - const signatureData = splitSignature(signature) - const message = WalletSigner.consentProofRequestText( - peerAddress, - timestampMs - ) - const digest = hexToBytes(hashMessage(message)) - // Recover public key - const publicKey = ecdsaSignerKey(digest, signatureData) - return publicKey?.getEthereumAddress() === this.client.address - } - - private async handleConsentProof( - consentProof: invitation.ConsentProofPayload, - peerAddress: string - ): Promise { - const { signature, timestamp } = consentProof - const isValid = await this.validateConsentSignature( - signature as `0x${string}`, - Number(timestamp), - peerAddress - ) - if (!isValid) { - return - } - const consentState = await this.client.contacts.consentState(peerAddress) - if (consentState === 'unknown') { - this.client.contacts.allow([peerAddress]) - } + return this.decodeInvites(envelopes) } private async decodeInvites( diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts index 42d2f929..25a4b733 100644 --- a/test/Contacts.test.ts +++ b/test/Contacts.test.ts @@ -1,6 +1,8 @@ +import { invitation } from '@xmtp/proto' import Client from '@/Client' import { Contacts } from '@/Contacts' -import { newWallet } from './helpers' +import { WalletSigner } from '@/crypto/Signature' +import { newLocalHostClient, newWallet } from './helpers' const alice = newWallet() const bob = newWallet() @@ -189,4 +191,116 @@ describe('Contacts', () => { expect(numActions).toBe(1) await aliceStream.return() }) + + describe('consent proofs', () => { + it('handles consent proof on invitation', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() + const alix = await Client.create(wallet, { + env: 'local', + }) + const timestamp = Date.now() + const consentMessage = WalletSigner.consentProofRequestText( + bo.address, + timestamp + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) + await alix.contacts.refreshConsentList() + const conversations = await alix.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + const isApproved = await convo?.isAllowed + expect(isApproved).toBe(true) + await alix.close() + await bo.close() + }) + + it('consent proof yields to network consent', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() + const alix1 = await Client.create(wallet, { + env: 'local', + }) + alix1.contacts.deny([bo.address]) + await alix1.close() + const alix2 = await Client.create(wallet, { + env: 'local', + }) + const timestamp = Date.now() + const consentMessage = WalletSigner.consentProofRequestText( + bo.address, + timestamp + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) + const conversations = await alix2.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + await alix2.contacts.refreshConsentList() + const isDenied = await alix2.contacts.isDenied(bo.address) + expect(isDenied).toBeTruthy() + await alix2.close() + await bo.close() + }) + + it('consent proof correctly validates', async () => { + const bo = await newLocalHostClient() + const wallet = newWallet() + const keySigner = new WalletSigner(wallet) + const alixAddress = await keySigner.wallet.getAddress() + const alix = await Client.create(wallet, { + env: 'local', + }) + const timestamp = Date.now() + const consentMessage = WalletSigner.consentProofRequestText( + bo.address, + timestamp + 1 + ) + const signedMessage = await keySigner.wallet.signMessage(consentMessage) + const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ + signature: signedMessage, + timestamp, + payloadVersion: + invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, + }) + const boConvo = await bo.conversations.newConversation( + alixAddress, + undefined, + consentProofPayload + ) + const conversations = await alix.conversations.list() + const convo = conversations.find((c) => c.topic === boConvo.topic) + expect(convo).toBeTruthy() + await alix.contacts.refreshConsentList() + const isAllowed = await alix.contacts.isAllowed(bo.address) + expect(isAllowed).toBeFalsy() + await alix.close() + await bo.close() + }) + }) }) diff --git a/test/conversations/Conversations.test.ts b/test/conversations/Conversations.test.ts index 1442b650..45a80883 100644 --- a/test/conversations/Conversations.test.ts +++ b/test/conversations/Conversations.test.ts @@ -1,10 +1,8 @@ -import { invitation } from '@xmtp/proto' -import Client from '@/Client' +import type Client from '@/Client' import { ConversationV1, ConversationV2 } from '@/conversations/Conversation' -import { WalletSigner } from '@/crypto/Signature' import { sleep } from '@/utils/async' import { buildDirectMessageTopic, buildUserIntroTopic } from '@/utils/topic' -import { newLocalHostClient, newWallet } from '@test/helpers' +import { newLocalHostClient } from '@test/helpers' describe('conversations', () => { describe('listConversations', () => { @@ -282,112 +280,6 @@ describe('conversations', () => { const invites = await alice.listInvitations() expect(invites).toHaveLength(1) }) - - it('handles consent proof on invitation', async () => { - const bo = await newLocalHostClient() - const wallet = newWallet() - const keySigner = new WalletSigner(wallet) - const alixAddress = await keySigner.wallet.getAddress() - const alix = await Client.create(wallet, { - env: 'local', - }) - const timestamp = Date.now() - const consentMessage = WalletSigner.consentProofRequestText( - bo.address, - timestamp - ) - const signedMessage = await keySigner.wallet.signMessage(consentMessage) - const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ - signature: signedMessage, - timestamp, - payloadVersion: - invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, - }) - const boConvo = await bo.conversations.newConversation( - alixAddress, - undefined, - consentProofPayload - ) - const conversations = await alix.conversations.list() - const convo = conversations.find((c) => c.topic === boConvo.topic) - expect(convo).toBeTruthy() - await alix.contacts.refreshConsentList() - const isApproved = await alix.contacts.isAllowed(bo.address) - expect(isApproved).toBeTruthy() - await alix.close() - await bo.close() - }) - - it('consent proof yields to network consent', async () => { - const bo = await newLocalHostClient() - const wallet = newWallet() - const keySigner = new WalletSigner(wallet) - const alixAddress = await keySigner.wallet.getAddress() - const alix = await Client.create(wallet, { - env: 'local', - }) - const timestamp = Date.now() - const consentMessage = WalletSigner.consentProofRequestText( - bo.address, - timestamp - ) - const signedMessage = await keySigner.wallet.signMessage(consentMessage) - const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ - signature: signedMessage, - timestamp, - payloadVersion: - invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, - }) - const boConvo = await bo.conversations.newConversation( - alixAddress, - undefined, - consentProofPayload - ) - await alix.contacts.deny([bo.address]) - const conversations = await alix.conversations.list() - const convo = conversations.find((c) => c.topic === boConvo.topic) - expect(convo).toBeTruthy() - await alix.contacts.refreshConsentList() - const isDenied = await alix.contacts.isDenied(bo.address) - expect(isDenied).toBeTruthy() - await alix.close() - await bo.close() - }) - - it('consent proof correctly validates', async () => { - const bo = await newLocalHostClient() - const wallet = newWallet() - const keySigner = new WalletSigner(wallet) - const alixAddress = await keySigner.wallet.getAddress() - const alix = await Client.create(wallet, { - env: 'local', - }) - const timestamp = Date.now() - const consentMessage = WalletSigner.consentProofRequestText( - bo.address, - timestamp + 1 - ) - const signedMessage = await keySigner.wallet.signMessage(consentMessage) - const consentProofPayload = invitation.ConsentProofPayload.fromPartial({ - signature: signedMessage, - timestamp, - payloadVersion: - invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1, - }) - const boConvo = await bo.conversations.newConversation( - alixAddress, - undefined, - consentProofPayload - ) - const conversations = await alix.conversations.list() - const convo = conversations.find((c) => c.topic === boConvo.topic) - expect(convo).toBeTruthy() - await alix.contacts.refreshConsentList() - const isAllowed = await alix.contacts.isAllowed(bo.address) - expect(isAllowed).toBeFalsy() - await alix.close() - await bo.close() - }) }) }) From 240faf247456b0493fcc9cad6fa089a30c269efc Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 1 May 2024 15:16:32 -0600 Subject: [PATCH 7/8] feat: update from feedback Updated to validate and approve as array Added timestamp validation --- src/Contacts.ts | 58 ++++++++++++++++++++++++++++++------------- test/Contacts.test.ts | 7 ------ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/Contacts.ts b/src/Contacts.ts index 2f6140c5..340d20c9 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -260,6 +260,15 @@ export class Contacts { timestampMs: number, peerAddress: string ): Promise { + if (!signature || !timestampMs) { + return false + } + if (timestampMs > Date.now()) { + return false + } + if (timestampMs < Date.now() - 1000 * 60 * 60 * 24 * 30) { + return false + } const signatureData = splitSignature(signature) const message = WalletSigner.consentProofRequestText( peerAddress, @@ -271,20 +280,28 @@ export class Contacts { return publicKey?.getEthereumAddress() === this.client.address } - private async handleConsentProof( - consentProof: invitation.ConsentProofPayload, - peerAddress: string - ): Promise { - const { signature, timestamp } = consentProof - const isValid = await this.validateConsentSignature( - signature as `0x${string}`, - Number(timestamp), - peerAddress + private async handleConsentProofs( + consentProofs: { + consentProof: invitation.ConsentProofPayload + peerAddress: string + }[] + ) { + const validConsentProofAddresses: string[] = [] + const validationResults = await Promise.allSettled( + consentProofs.map((proofItem) => { + return this.validateConsentSignature( + proofItem.consentProof.signature as `0x${string}`, + Number(proofItem.consentProof.timestamp), + proofItem.peerAddress + ) + }) ) - if (!isValid) { - return - } - await this.client.contacts.allow([peerAddress]) + validationResults.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) { + validConsentProofAddresses.push(consentProofs[index].peerAddress) + } + }) + this.client.contacts.allow(validConsentProofAddresses) } async loadConsentList(startTime?: Date) { @@ -293,17 +310,24 @@ export class Contacts { const entries = await this.consentList.load(startTime ?? lastRun) try { const conversations = await this.client.conversations.list() + const consentProofs: { + consentProof: invitation.ConsentProofPayload + peerAddress: string + }[] = [] conversations.forEach((conversation) => { if ( conversation.consentProof && this.consentState(conversation.peerAddress) === 'unknown' ) { - this.handleConsentProof( - conversation.consentProof, - conversation.peerAddress - ) + consentProofs.push({ + consentProof: conversation.consentProof, + peerAddress: conversation.peerAddress, + }) } }) + if (consentProofs.length) { + this.handleConsentProofs(consentProofs) + } } catch (err) { console.log(err) } diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts index 25a4b733..75330053 100644 --- a/test/Contacts.test.ts +++ b/test/Contacts.test.ts @@ -224,8 +224,6 @@ describe('Contacts', () => { expect(convo).toBeTruthy() const isApproved = await convo?.isAllowed expect(isApproved).toBe(true) - await alix.close() - await bo.close() }) it('consent proof yields to network consent', async () => { @@ -237,7 +235,6 @@ describe('Contacts', () => { env: 'local', }) alix1.contacts.deny([bo.address]) - await alix1.close() const alix2 = await Client.create(wallet, { env: 'local', }) @@ -264,8 +261,6 @@ describe('Contacts', () => { await alix2.contacts.refreshConsentList() const isDenied = await alix2.contacts.isDenied(bo.address) expect(isDenied).toBeTruthy() - await alix2.close() - await bo.close() }) it('consent proof correctly validates', async () => { @@ -299,8 +294,6 @@ describe('Contacts', () => { await alix.contacts.refreshConsentList() const isAllowed = await alix.contacts.isAllowed(bo.address) expect(isAllowed).toBeFalsy() - await alix.close() - await bo.close() }) }) }) From a4959affd2c2e95d7ec44f3bb89c18c953cd0706 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 1 May 2024 16:03:03 -0600 Subject: [PATCH 8/8] feat: consent proofs Validate signatures synchronously Moved to reduce Removed handle Consent proof method --- src/Contacts.ts | 76 +++++++++++++++++++------------------------------ 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/src/Contacts.ts b/src/Contacts.ts index 340d20c9..74b0f314 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -255,21 +255,26 @@ export class Contacts { this.jobRunner = new JobRunner('user-preferences', client.keystore) } - private async validateConsentSignature( - signature: `0x${string}`, - timestampMs: number, + /** + * Validate the signature and timestamp of a consent proof + */ + private validateConsentSignature( + { signature, timestamp }: invitation.ConsentProofPayload, peerAddress: string - ): Promise { + ): boolean { + const timestampMs = Number(timestamp) if (!signature || !timestampMs) { return false } + // timestamp should be in the past if (timestampMs > Date.now()) { return false } + // timestamp should be within the last 30 days if (timestampMs < Date.now() - 1000 * 60 * 60 * 24 * 30) { return false } - const signatureData = splitSignature(signature) + const signatureData = splitSignature(signature as `0x${string}`) const message = WalletSigner.consentProofRequestText( peerAddress, timestampMs @@ -280,58 +285,35 @@ export class Contacts { return publicKey?.getEthereumAddress() === this.client.address } - private async handleConsentProofs( - consentProofs: { - consentProof: invitation.ConsentProofPayload - peerAddress: string - }[] - ) { - const validConsentProofAddresses: string[] = [] - const validationResults = await Promise.allSettled( - consentProofs.map((proofItem) => { - return this.validateConsentSignature( - proofItem.consentProof.signature as `0x${string}`, - Number(proofItem.consentProof.timestamp), - proofItem.peerAddress - ) - }) - ) - validationResults.forEach((result, index) => { - if (result.status === 'fulfilled' && result.value) { - validConsentProofAddresses.push(consentProofs[index].peerAddress) - } - }) - this.client.contacts.allow(validConsentProofAddresses) - } - async loadConsentList(startTime?: Date) { return this.jobRunner.run(async (lastRun) => { // allow for override of startTime const entries = await this.consentList.load(startTime ?? lastRun) try { const conversations = await this.client.conversations.list() - const consentProofs: { - consentProof: invitation.ConsentProofPayload - peerAddress: string - }[] = [] - conversations.forEach((conversation) => { - if ( - conversation.consentProof && - this.consentState(conversation.peerAddress) === 'unknown' - ) { - consentProofs.push({ - consentProof: conversation.consentProof, - peerAddress: conversation.peerAddress, - }) - } - }) - if (consentProofs.length) { - this.handleConsentProofs(consentProofs) + const validConsentProofAddresses: string[] = conversations.reduce( + (result, conversation) => { + if ( + conversation.consentProof && + this.consentState(conversation.peerAddress) === 'unknown' && + this.validateConsentSignature( + conversation.consentProof, + conversation.peerAddress + ) + ) { + return result.concat(conversation.peerAddress) + } else { + return result + } + }, + [] as string[] + ) + if (validConsentProofAddresses.length) { + this.client.contacts.allow(validConsentProofAddresses) } } catch (err) { console.log(err) } - return entries }) }