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 6 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
57 changes: 55 additions & 2 deletions src/Contacts.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<boolean> {
const signatureData = splitSignature(signature)
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
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
}

rygine marked this conversation as resolved.
Show resolved Hide resolved
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
}
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'
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good. It'll always have the current value from the network because this.consentList.load(...) mutates this.consentList as it goes, right?

this.handleConsentProof(
conversation.consentProof,
conversation.peerAddress
)
}
})
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
console.log(err)
}

return entries
})
}

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
@@ -1,6 +1,7 @@
import {
message,
content as proto,
type invitation,
type keystore,
type messageApi,
} from '@xmtp/proto'
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?: invitation.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?: invitation.ConsentProofPayload

constructor(
client: Client<ContentTypes>,
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() {
Expand All @@ -541,6 +550,10 @@ export class ConversationV2<ContentTypes>
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
*/
Expand Down
28 changes: 19 additions & 9 deletions src/conversations/Conversations.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -88,6 +92,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 @@ -148,7 +153,6 @@ export default class Conversations<ContentTypes = any> {
startTime,
direction: SortDirection.SORT_DIRECTION_ASCENDING,
})

return this.decodeInvites(envelopes)
}

Expand Down Expand Up @@ -198,7 +202,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 +474,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 +490,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 +539,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
Loading
Loading