From cbd9f2bd704f6e20649c317436fcbb613f6db707 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Tue, 24 Oct 2023 20:00:11 -0500 Subject: [PATCH] feat: add PPPP support --- package-lock.json | 14 +- package.json | 3 +- src/Client.ts | 20 ++- src/Contacts.ts | 203 +++++++++++++++++++++++++++++ src/conversations/Conversation.ts | 28 +++- src/conversations/Conversations.ts | 8 ++ src/keystore/InMemoryKeystore.ts | 73 +++++++++++ src/keystore/encryption.ts | 22 ++++ src/keystore/interfaces.ts | 16 +++ src/keystore/rpcDefinitions.ts | 12 ++ src/utils/topic.ts | 4 + 11 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 src/Contacts.ts diff --git a/package-lock.json b/package-lock.json index 4f00959e3..8c3543735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.29.0", + "@xmtp/ecies-bindings-wasm": "^0.1.7", + "@xmtp/proto": "^3.32.0", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", @@ -4819,10 +4820,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@xmtp/ecies-bindings-wasm": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@xmtp/ecies-bindings-wasm/-/ecies-bindings-wasm-0.1.7.tgz", + "integrity": "sha512-+bwI5koXneyRLVUh9Mpm9Md7A1w8GdEKqPPEVhaszfWyGr1eSeMOnkLZ0JCXMxCirYJcmiC/aua96LiuAQpACQ==" + }, "node_modules/@xmtp/proto": { - "version": "3.29.0", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.29.0.tgz", - "integrity": "sha512-+ibo+u6NwdzfLN3QEDMiNrnXd7eT1/+F2j5WWz3b4mk91wgn8lJ66fxFPwLTQs6AbaBBUmhO2cdpgIL/g4kvZg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.32.0.tgz", + "integrity": "sha512-rA05O3CtGEsVl5cLr9qCFg08lKeNiWsJiAOt33I1u9hQPgzm4JOywnc6GJrJomwED3cZ1TsKTf/1sj3yCzaDIA==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index 1485823f8..cd13c3648 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.29.0", + "@xmtp/ecies-bindings-wasm": "^0.1.7", + "@xmtp/proto": "^3.32.0", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", diff --git a/src/Client.ts b/src/Client.ts index 50b233abf..3a33ff694 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -44,6 +44,7 @@ import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' import { ExtractDecodedType } from './types/client' import type { WalletClient } from 'viem' +import { Contacts } from './Contacts' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -194,6 +195,10 @@ export type PreEventCallbackOptions = { preEnableIdentityCallback?: PreEventCallback } +export type AllowListOptions = { + enableAllowList: boolean +} + /** * Aggregate type for client options. Optional properties are used when the default value is calculated on invocation, and are computed * as needed by each function. All other defaults are specified in defaultOptions. @@ -203,7 +208,8 @@ export type ClientOptions = Flatten< KeyStoreOptions & ContentOptions & LegacyOptions & - PreEventCallbackOptions + PreEventCallbackOptions & + AllowListOptions > /** @@ -226,6 +232,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { disablePersistenceEncryption: false, keystoreProviders: defaultKeystoreProviders(), apiClientFactory: createHttpApiClientFromOptions, + enableAllowList: false, } if (opts?.codecs) { @@ -251,7 +258,7 @@ export default class Client { address: string keystore: Keystore apiClient: ApiClient - contacts: Set // address which we have connected to + contacts: Contacts publicKeyBundle: PublicKeyBundle private knownPublicKeyBundles: Map< string, @@ -263,14 +270,16 @@ export default class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any private _codecs: Map> private _maxContentSize: number + readonly _enableAllowList: boolean constructor( publicKeyBundle: PublicKeyBundle, apiClient: ApiClient, backupClient: BackupClient, - keystore: Keystore + keystore: Keystore, + enableAllowList: boolean = false ) { - this.contacts = new Set() + this.contacts = new Contacts(this) this.knownPublicKeyBundles = new Map< string, PublicKeyBundle | SignedPublicKeyBundle @@ -284,6 +293,7 @@ export default class Client { this._maxContentSize = MaxContentSize this.apiClient = apiClient this._backupClient = backupClient + this._enableAllowList = enableAllowList } /** @@ -328,7 +338,7 @@ export default class Client { const backupClient = await Client.setupBackupClient(address, options.env) const client = new Client< ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - >(publicKeyBundle, apiClient, backupClient, keystore) + >(publicKeyBundle, apiClient, backupClient, keystore, opts?.enableAllowList) await client.init(options) return client } diff --git a/src/Contacts.ts b/src/Contacts.ts new file mode 100644 index 000000000..91c2cfd6a --- /dev/null +++ b/src/Contacts.ts @@ -0,0 +1,203 @@ +import { Envelope } from '@xmtp/proto/ts/dist/types/message_api/v1/message_api.pb' +import Client from './Client' +import { privatePreferences } from '@xmtp/proto' +import { buildUserPrivatePreferencesTopic } from './utils' +import { PublishParams } from './ApiClient' + +export type AllowListPermissionType = 'allow' | 'block' | 'unknown' + +export type AllowListEntryType = 'address' + +export class AllowListEntry { + value: string + entryType: AllowListEntryType + permissionType: AllowListPermissionType + + constructor( + value: string, + entryType: AllowListEntryType, + permissionType: AllowListPermissionType + ) { + this.value = value + this.entryType = entryType + this.permissionType = permissionType + } + + get key(): string { + return `${this.entryType}-${this.value}` + } + + static fromAddress( + address: string, + permissionType: AllowListPermissionType = 'unknown' + ): AllowListEntry { + return new AllowListEntry(address, 'address', permissionType) + } +} + +export class AllowList { + entries: Map + static _identifier: string + + constructor() { + this.entries = new Map() + } + + allow(address: string) { + const entry = AllowListEntry.fromAddress(address, 'allow') + this.entries.set(entry.key, 'allow') + return entry + } + + block(address: string) { + const entry = AllowListEntry.fromAddress(address, 'block') + this.entries.set(entry.key, 'block') + return entry + } + + state(address: string) { + const entry = AllowListEntry.fromAddress(address) + return this.entries.get(entry.key) ?? 'unknown' + } + + static async getIdentifier(client: Client): Promise { + if (!this._identifier) { + const { identifier } = + await client.keystore.getPrivatePreferencesTopicIdentifier() + this._identifier = identifier + } + return this._identifier + } + + static async load(client: Client): Promise { + const allowList = new AllowList() + const identifier = await this.getIdentifier(client) + const envelopes = ( + await client.listEnvelopes( + buildUserPrivatePreferencesTopic(identifier), + async ({ message }: Envelope) => { + if (message) { + const result = await client.keystore.selfDecrypt({ + requests: [{ payload: message }], + }) + const payload = result.responses[0].result?.decrypted + if (payload) { + return privatePreferences.PrivatePreferencesAction.decode(payload) + } + } + return undefined + } + ) + ).filter( + (envelope) => envelope !== undefined + ) as privatePreferences.PrivatePreferencesAction[] + + envelopes.forEach((envelope) => { + envelope.allow?.walletAddresses.forEach((address) => { + allowList.allow(address) + }) + envelope.block?.walletAddresses.forEach((address) => { + allowList.block(address) + }) + }) + + return allowList + } + + static async publish(entries: AllowListEntry[], client: Client) { + const identifier = await this.getIdentifier(client) + + // TODO: preserve order + const rawEnvelopes = await Promise.all( + entries.map(async (entry) => { + if (entry.entryType === 'address') { + const action: privatePreferences.PrivatePreferencesAction = { + allow: + entry.permissionType === 'allow' + ? { + walletAddresses: [entry.value], + } + : undefined, + block: + entry.permissionType === 'block' + ? { + walletAddresses: [entry.value], + } + : undefined, + } + const payload = + privatePreferences.PrivatePreferencesAction.encode(action).finish() + const result = await client.keystore.selfEncrypt({ + requests: [{ payload }], + }) + const message = result.responses[0].result?.encrypted + if (message) { + return { + contentTopic: buildUserPrivatePreferencesTopic(identifier), + message, + timestamp: new Date(), + } + } + } + return undefined + }) + ) + + const envelopes = rawEnvelopes.filter( + (envelope) => envelope !== undefined + ) as PublishParams[] + + await client.publishEnvelopes(envelopes) + } +} + +export class Contacts { + /** + * Addresses that the client has connected to + */ + addresses: Set + allowList: AllowList + client: Client + + constructor(client: Client) { + this.addresses = new Set() + this.allowList = new AllowList() + this.client = client + } + + async refreshAllowList() { + if (this.client._enableAllowList) { + this.allowList = await AllowList.load(this.client) + } + } + + isAllowed(address: string) { + return this.allowList.state(address) === 'allow' + } + + isBlocked(address: string) { + return this.allowList.state(address) === 'block' + } + + allowState(address: string) { + return this.allowList.state(address) + } + + async allow(addresses: string[]) { + if (this.client._enableAllowList) { + await AllowList.publish( + addresses.map((address) => this.allowList.allow(address)), + this.client + ) + } + } + + async block(addresses: string[]) { + if (this.client._enableAllowList) { + await AllowList.publish( + addresses.map((address) => this.allowList.block(address)), + this.client + ) + } + } +} diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 65fb7fccf..9042e902f 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -237,13 +237,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -345,13 +345,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -475,6 +475,26 @@ export class ConversationV2 return this.client.address } + async allow() { + await this.client.contacts.allow([this.peerAddress]) + } + + async block() { + await this.client.contacts.block([this.peerAddress]) + } + + get isAllowed() { + return this.client.contacts.isAllowed(this.peerAddress) + } + + get isBlocked() { + return this.client.contacts.isBlocked(this.peerAddress) + } + + get allowState() { + return this.client.contacts.allowState(this.peerAddress) + } + /** * Returns a list of all messages to/from the peerAddress */ diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index efabd1e86..b8661211b 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -49,6 +49,9 @@ export default class Conversations { this.listV2Conversations(), ]) + // fetch allow list if enabled + this.client.contacts.refreshAllowList() + const conversations = v1Convos.concat(v2Convos) conversations.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) @@ -567,6 +570,11 @@ export default class Conversations { }, ]) + // add peer address to allow list + if (this.client._enableAllowList) { + this.client.contacts.allow([peerAddress]) + } + return this.conversationReferenceToV2(conversation) } diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 1c773ec03..6fd6ff0a2 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -29,6 +29,9 @@ import { hmacSha256Sign } from '../crypto/ecies' import crypto from '../crypto/crypto' import { bytesToHex } from '../crypto/utils' import Long from 'long' +import { selfDecrypt, selfEncrypt } from '../keystore/encryption' +// eslint-disable-next-line camelcase +import { generate_private_preferences_topic } from '@xmtp/ecies-bindings-wasm' const { ErrorCode } = keystore @@ -197,6 +200,76 @@ export default class InMemoryKeystore implements Keystore { ) } + async selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + const publicKey = + this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + + return { + encrypted: await selfEncrypt(publicKey, privateKey, payload), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.SelfEncryptResponse.fromPartial({ + responses, + }) + } + + async selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + const publicKey = + this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + + return { + decrypted: await selfDecrypt(publicKey, privateKey, payload), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.DecryptResponse.fromPartial({ + responses, + }) + } + + async getPrivatePreferencesTopicIdentifier(): Promise { + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + const identifier = generate_private_preferences_topic(privateKey).toString() + return keystore.GetPrivatePreferencesTopicIdentifierResponse.fromPartial({ + identifier, + }) + } + async encryptV2( req: keystore.EncryptV2Request ): Promise { diff --git a/src/keystore/encryption.ts b/src/keystore/encryption.ts index c0f6e5143..9d849accb 100644 --- a/src/keystore/encryption.ts +++ b/src/keystore/encryption.ts @@ -5,6 +5,12 @@ import { decrypt, } from '../crypto' import { ciphertext } from '@xmtp/proto' +import { + // eslint-disable-next-line camelcase + ecies_decrypt_k256_sha3_256, + // eslint-disable-next-line camelcase + ecies_encrypt_k256_sha3_256, +} from '@xmtp/ecies-bindings-wasm' export const decryptV1 = async ( myKeys: PrivateKeyBundleV1, @@ -48,3 +54,19 @@ export const encryptV2 = ( secret: Uint8Array, headerBytes: Uint8Array ) => encrypt(payload, secret, headerBytes) + +export async function selfEncrypt( + publicKey: Uint8Array, + privateKey: Uint8Array, + payload: Uint8Array +) { + return ecies_encrypt_k256_sha3_256(publicKey, privateKey, payload) +} + +export async function selfDecrypt( + publicKey: Uint8Array, + privateKey: Uint8Array, + payload: Uint8Array +) { + return ecies_decrypt_k256_sha3_256(publicKey, privateKey, payload) +} diff --git a/src/keystore/interfaces.ts b/src/keystore/interfaces.ts index 4e70e9b7b..15382f7b2 100644 --- a/src/keystore/interfaces.ts +++ b/src/keystore/interfaces.ts @@ -81,6 +81,22 @@ export interface Keystore { * Get the account address of the wallet used to create the Keystore */ getAccountAddress(): Promise + /** + * Encrypt a batch of messages to yourself + */ + selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise + /** + * Decrypt a batch of messages to yourself + */ + selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise + /** + * Get the private preferences topic identifier + */ + getPrivatePreferencesTopicIdentifier(): Promise } export type TopicData = WithoutUndefined diff --git a/src/keystore/rpcDefinitions.ts b/src/keystore/rpcDefinitions.ts index b362d3e7f..3d04d5208 100644 --- a/src/keystore/rpcDefinitions.ts +++ b/src/keystore/rpcDefinitions.ts @@ -73,4 +73,16 @@ export const apiDefs: ApiDefs = { req: keystore.SetRefeshJobRequest, res: keystore.SetRefreshJobResponse, }, + selfEncrypt: { + req: keystore.SelfEncryptRequest, + res: keystore.SelfEncryptResponse, + }, + selfDecrypt: { + req: keystore.SelfDecryptRequest, + res: keystore.DecryptResponse, + }, + getPrivatePreferencesTopicIdentifier: { + req: null, + res: keystore.GetPrivatePreferencesTopicIdentifierResponse, + }, } as const diff --git a/src/utils/topic.ts b/src/utils/topic.ts index 5290cbf51..125cca5b5 100644 --- a/src/utils/topic.ts +++ b/src/utils/topic.ts @@ -31,7 +31,11 @@ export const buildUserInviteTopic = (walletAddr: string): string => { // EIP55 normalize the address case. return buildContentTopic(`invite-${utils.getAddress(walletAddr)}`) } + export const buildUserPrivateStoreTopic = (addrPrefixedKey: string): string => { // e.g. "0x1111111111222222222233333333334444444444/key_bundle" return buildContentTopic(`privatestore-${addrPrefixedKey}`) } + +export const buildUserPrivatePreferencesTopic = (identifier: string) => + buildContentTopic(`pppp-${identifier}`)