Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: consent proofs #599

Merged
merged 8 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bench/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions bench/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -56,6 +62,7 @@ export class InvitationV1 implements invitation.InvitationV1 {
topic,
aes256GcmHkdfSha256: { keyMaterial },
context,
consentProof,
})
}

Expand Down
4 changes: 3 additions & 1 deletion src/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export class DecodedMessage<ContentTypes = any> {
context: this.conversation.context ?? undefined,
createdNs: dateToNs(this.conversation.createdAt),
peerAddress: this.conversation.peerAddress,
consentProofPayload: this.conversation.consentProof ?? undefined,
},
sentNs: dateToNs(this.sent),
}).finish()
Expand Down Expand Up @@ -395,7 +396,8 @@ function conversationReferenceToConversation<ContentTypes>(
reference.topic,
reference.peerAddress,
nsToDate(reference.createdNs),
reference.context
reference.context,
reference.consentProofPayload
)
}
throw new Error(`Unknown conversation version ${version}`)
Expand Down
15 changes: 14 additions & 1 deletion src/conversations/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
import { getAddress } from 'viem'
import type { OnConnectionLostCallback } from '@/ApiClient'
import type {
Expand Down Expand Up @@ -85,6 +86,11 @@ export interface Conversation<ContentTypes = any> {
*/
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.
*
Expand Down Expand Up @@ -502,19 +508,22 @@ export class ConversationV2<ContentTypes>
peerAddress: string
createdAt: Date
context?: InvitationContext
consentProof?: ConsentProofPayload

constructor(
client: Client<ContentTypes>,
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() {
Expand All @@ -541,6 +550,10 @@ export class ConversationV2<ContentTypes>
return this.client.contacts.consentState(this.peerAddress)
}

get consentProofPayload(): ConsentProofPayload | undefined {
return this.consentProof
}

/**
* Returns a list of all messages to/from the peerAddress
*/
Expand Down
73 changes: 65 additions & 8 deletions src/conversations/Conversations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type { conversationReference, keystore, messageApi } from '@xmtp/proto'
import type {
conversationReference,
invitation,
keystore,
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'
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'
Expand All @@ -27,7 +35,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
*/
Expand Down Expand Up @@ -88,6 +95,7 @@ export default class Conversations<ContentTypes = any> {
createdNs: dateToNs(createdAt),
topic: buildDirectMessageTopic(peerAddress, this.client.address),
context: undefined,
consentProofPayload: undefined,
}))
.filter((c) => isValidTopic(c.topic)),
})
Expand Down Expand Up @@ -152,6 +160,43 @@ export default class Conversations<ContentTypes = any> {
return this.decodeInvites(envelopes)
}

private async validateConsentSignature(
signature: `0x${string}`,
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
timestamp: number,
peerAddress: string
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
): Promise<boolean> {
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<void> {
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') {
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
this.client.contacts.allow([peerAddress])
}
}

private async decodeInvites(
envelopes: messageApi.Envelope[],
shouldThrow = false
Expand All @@ -169,6 +214,12 @@ export default class Conversations<ContentTypes = any> {
const out: ConversationV2<ContentTypes>[] = []
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)
Expand Down Expand Up @@ -198,7 +249,8 @@ export default class Conversations<ContentTypes = any> {
convoRef.topic,
convoRef.peerAddress,
nsToDate(convoRef.createdNs),
convoRef.context
convoRef.context,
convoRef.consentProofPayload
)
}

Expand Down Expand Up @@ -469,7 +521,8 @@ export default class Conversations<ContentTypes = any> {
*/
async newConversation(
peerAddress: string,
context?: InvitationContext
context?: InvitationContext,
consentProof?: invitation.ConsentProofPayload
): Promise<Conversation<ContentTypes>> {
let contact = await this.client.getUserContact(peerAddress)
if (!contact) {
Expand All @@ -484,7 +537,6 @@ export default class Conversations<ContentTypes = any> {
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()
Expand Down Expand Up @@ -534,20 +586,25 @@ export default class Conversations<ContentTypes = any> {
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<ConversationV2<ContentTypes>> {
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')
Expand Down
14 changes: 14 additions & 0 deletions src/crypto/Signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/keystore/InMemoryKeystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -575,6 +576,7 @@ export default class InMemoryKeystore implements KeystoreInterface {
createdNs: data.createdNs,
topic: buildDirectMessageTopic(data.peerAddress, this.walletAddress),
context: undefined,
consentProofPayload: undefined,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/keystore/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const topicDataToV2ConversationReference = ({
topic: invitation.topic,
peerAddress,
createdNs,
consentProofPayload: invitation.consentProof,
})

export const isCompleteTopicData = (
Expand Down
4 changes: 4 additions & 0 deletions test/Invitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const createInvitation = (): InvitationV1 => {
aes256GcmHkdfSha256: {
keyMaterial: crypto.getRandomValues(new Uint8Array(32)),
},
consentProof: undefined,
})
}

Expand Down Expand Up @@ -192,6 +193,7 @@ describe('Invitations', () => {
aes256GcmHkdfSha256: {
keyMaterial,
},
consentProof: undefined,
})

expect(invite.topic).toEqual(topic)
Expand All @@ -213,6 +215,7 @@ describe('Invitations', () => {
topic,
context: undefined,
aes256GcmHkdfSha256: { keyMaterial: new Uint8Array() },
consentProof: undefined,
})
).toThrow('Missing key material')

Expand All @@ -222,6 +225,7 @@ describe('Invitations', () => {
topic: '',
context: undefined,
aes256GcmHkdfSha256: { keyMaterial },
consentProof: undefined,
})
).toThrow('Missing topic')
})
Expand Down
Loading
Loading