Skip to content

Commit

Permalink
feat: consent proofs
Browse files Browse the repository at this point in the history
Updated Protos
Added ability to add consent proofs to conversations
Updated types
Added consentProof on Invitiation class
  • Loading branch information
Alex Risch authored and Alex Risch committed Apr 23, 2024
1 parent e5fe7f0 commit 0b777bc
Show file tree
Hide file tree
Showing 15 changed files with 1,932 additions and 1,055 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
7 changes: 6 additions & 1 deletion src/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
Expand All @@ -56,6 +60,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'
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
32 changes: 26 additions & 6 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 @@ -169,6 +174,13 @@ export default class Conversations<ContentTypes = any> {
const out: ConversationV2<ContentTypes>[] = []
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)
Expand Down Expand Up @@ -198,7 +210,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 +482,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 Down Expand Up @@ -535,19 +549,25 @@ export default class Conversations<ContentTypes = any> {
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
41 changes: 39 additions & 2 deletions test/conversations/Conversations.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
})

Expand Down
Loading

0 comments on commit 0b777bc

Please sign in to comment.