Skip to content

Commit

Permalink
Merge pull request #599 from xmtp/ar/consent-proofs
Browse files Browse the repository at this point in the history
feat: consent proofs
  • Loading branch information
alexrisch committed May 1, 2024
2 parents 4299e7d + a4959af commit 56d3cee
Show file tree
Hide file tree
Showing 16 changed files with 259 additions and 21 deletions.
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
63 changes: 61 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,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
})
}

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

0 comments on commit 56d3cee

Please sign in to comment.