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/Contacts.ts b/src/Contacts.ts index ed8b3e45..74b0f314 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,66 @@ export class Contacts { this.jobRunner = new JobRunner('user-preferences', client.keystore) } + /** + * Validate the signature and timestamp of a consent proof + */ + private validateConsentSignature( + { signature, timestamp }: invitation.ConsentProofPayload, + peerAddress: string + ): 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 as `0x${string}`) + 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 + } + 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() + 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 }) } diff --git a/src/Invitation.ts b/src/Invitation.ts index c2889c3b..3a38bac4 100644 --- a/src/Invitation.ts +++ b/src/Invitation.ts @@ -20,11 +20,13 @@ 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, context, aes256GcmHkdfSha256, + consentProof, }: invitation.InvitationV1) { if (!topic || !topic.length) { throw new Error('Missing topic') @@ -39,9 +41,13 @@ export class InvitationV1 implements invitation.InvitationV1 { this.topic = topic this.context = context this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256 + this.consentProof = consentProof } - 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 +62,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..340c3f54 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -1,6 +1,7 @@ import { message, content as proto, + type invitation, type keystore, type messageApi, } from '@xmtp/proto' @@ -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?: invitation.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?: invitation.ConsentProofPayload constructor( client: Client, topic: string, peerAddress: string, createdAt: Date, - context: InvitationContext | undefined + context: InvitationContext | undefined, + consentProof: invitation.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(): invitation.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..96205057 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)), }) @@ -148,7 +153,6 @@ export default class Conversations { startTime, direction: SortDirection.SORT_DIRECTION_ASCENDING, }) - return this.decodeInvites(envelopes) } @@ -198,7 +202,8 @@ export default class Conversations { convoRef.topic, convoRef.peerAddress, nsToDate(convoRef.createdNs), - convoRef.context + convoRef.context, + convoRef.consentProofPayload ) } @@ -469,7 +474,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) { @@ -484,7 +490,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() @@ -534,20 +539,25 @@ export default class Conversations { if (newItemMatch) { 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/Contacts.test.ts b/test/Contacts.test.ts index 42d2f929..75330053 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,109 @@ 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) + }) + + 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]) + 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() + }) + + 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() + }) + }) }) 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/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"