diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 4e79d8979..151590484 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import org.json.JSONObject import org.xmtp.android.library.Client @@ -44,6 +45,7 @@ import org.xmtp.android.library.messages.InvitationV1ContextBuilder import org.xmtp.android.library.messages.Pagination import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.Signature +import org.xmtp.android.library.messages.getPublicKeyBundle import org.xmtp.android.library.push.XMTPPush import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.api.v1.MessageApiOuterClass @@ -224,6 +226,37 @@ class XMTPModule : Module() { } } + AsyncFunction("sign") { clientAddress: String, digest: List, keyType: String, preKeyIndex: Int -> + logV("sign") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val digestBytes = + digest.foldIndexed(ByteArray(digest.size)) { i, a, v -> + a.apply { + set( + i, + v.toByte() + ) + } + } + val privateKeyBundle = client.keys + val signedPrivateKey = if (keyType == "prekey") { + privateKeyBundle.preKeysList[preKeyIndex] + } else { + privateKeyBundle.identityKey + } + val signature = runBlocking { + val privateKey = PrivateKeyBuilder.buildFromSignedPrivateKey(signedPrivateKey) + PrivateKeyBuilder(privateKey).sign(digestBytes) + } + signature.toByteArray().map { it.toInt() and 0xFF } + } + + AsyncFunction("exportPublicKeyBundle") { clientAddress: String -> + logV("exportPublicKeyBundle") + val client = clients[clientAddress] ?: throw XMTPException("No client") + client.keys.getPublicKeyBundle().toByteArray().map { it.toInt() and 0xFF } + } + AsyncFunction("exportKeyBundle") { clientAddress: String -> logV("exportKeyBundle") val client = clients[clientAddress] ?: throw XMTPException("No client") diff --git a/example/package.json b/example/package.json index a2b4c2db5..62e3264b7 100644 --- a/example/package.json +++ b/example/package.json @@ -14,6 +14,7 @@ "@react-navigation/native-stack": "^6.9.12", "@thirdweb-dev/react-native": "^0.6.2", "@thirdweb-dev/react-native-compat": "^0.6.2", + "@xmtp/frames-client": "^0.3.2", "ethers": "^5.7.2", "expo": "~48.0.18", "expo-document-picker": "^11.5.4", diff --git a/example/src/tests.ts b/example/src/tests.ts index 1dff64244..f374e2365 100644 --- a/example/src/tests.ts +++ b/example/src/tests.ts @@ -1,3 +1,4 @@ +import { FramesClient } from '@xmtp/frames-client' import { content } from '@xmtp/proto' import ReactNativeBlobUtil from 'react-native-blob-util' import { TextEncoder, TextDecoder } from 'text-encoding' @@ -173,6 +174,20 @@ test('canMessage', async () => { return canMessage }) +test('fetch a public key bundle and sign a digest', async () => { + const bob = await Client.createRandom({ env: 'local' }) + const bytes = new Uint8Array([1, 2, 3]) + const signature = await bob.sign(bytes, { kind: 'identity' }) + if (signature.length === 0) { + throw new Error('signature was not returned') + } + const keyBundle = await bob.exportPublicKeyBundle() + if (keyBundle.length === 0) { + throw new Error('key bundle was not returned') + } + return true +}) + test('createFromKeyBundle throws error for non string value', async () => { try { const bytes = [1, 2, 3] @@ -925,3 +940,39 @@ test('correctly handles lowercase addresses', async () => { } return true }) + +test('instantiate frames client correctly', async () => { + const frameUrl = + 'https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8' + const client = await Client.createRandom({ env: 'local' }) + const framesClient = new FramesClient(client) + const metadata = await framesClient.proxy.readMetadata(frameUrl) + if (!metadata) { + throw new Error('metadata should exist') + } + const signedPayload = await framesClient.signFrameAction({ + frameUrl, + buttonIndex: 1, + conversationTopic: 'foo', + participantAccountAddresses: ['amal', 'bola'], + }) + const postUrl = metadata.extractedTags['fc:frame:post_url'] + const response = await framesClient.proxy.post(postUrl, signedPayload) + if (!response) { + throw new Error('response should exist') + } + if (response.extractedTags['fc:frame'] !== 'vNext') { + throw new Error('response should have expected extractedTags') + } + const imageUrl = response.extractedTags['fc:frame:image'] + const mediaUrl = framesClient.proxy.mediaUrl(imageUrl) + + const downloadedMedia = await fetch(mediaUrl) + if (!downloadedMedia.ok) { + throw new Error('downloadedMedia should be ok') + } + if (downloadedMedia.headers.get('content-type') !== 'image/png') { + throw new Error('downloadedMedia should be image/png') + } + return true +}) diff --git a/example/yarn.lock b/example/yarn.lock index 765def3de..b2fa7e152 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -4632,6 +4632,11 @@ find-up "^5.0.0" js-yaml "^4.1.0" +"@fastify/busboy@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.0.tgz#0709e9f4cb252351c609c6e6d8d6779a8d25edff" + integrity sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA== + "@fastify/cookie@^9.1.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@fastify/cookie/-/cookie-9.3.0.tgz#229d8cfb754a60d815b98215a600a12e7d4b83c1" @@ -5075,7 +5080,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== -"@noble/hashes@1.3.3", "@noble/hashes@^1.3.2", "@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1": +"@noble/hashes@1.3.3", "@noble/hashes@^1.3.2", "@noble/hashes@^1.3.3", "@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1": version "1.3.3" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== @@ -6788,6 +6793,25 @@ resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3" integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g== +"@xmtp/frames-client@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@xmtp/frames-client/-/frames-client-0.3.2.tgz#6c860e11cbf7a63aa956543b4941ee72738ed211" + integrity sha512-61rxA7YcNUUKndQ9e5X44LNVwWJCrrZR6sBGOWLckfMK00LqRasoiLgom1PlR34c17a59PPZmagxoNQ2QCto7A== + dependencies: + "@noble/hashes" "^1.3.3" + "@xmtp/proto" "3.41.0-beta.5" + long "^5.2.3" + +"@xmtp/proto@3.41.0-beta.5": + version "3.41.0-beta.5" + resolved "https://registry.yarnpkg.com/@xmtp/proto/-/proto-3.41.0-beta.5.tgz#fe6d2f4f0a37e69c18c516ed0796a48fb16574db" + integrity sha512-vx5zqLpAVPjTEdyqY/woXrgvWMKjbTwwco+x9WE+T1iVlv+472yp2DwFJRLpfeQByC1cHl7XQyuO2Q+8t8HL4Q== + dependencies: + long "^5.2.0" + protobufjs "^7.0.0" + rxjs "^7.8.0" + undici "^5.8.1" + JSONStream@^1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -11465,7 +11489,7 @@ logkitty@^0.7.1: dayjs "^1.8.15" yargs "^15.1.0" -long@^5.0.0: +long@^5.0.0, long@^5.2.0, long@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== @@ -13920,6 +13944,13 @@ rxjs@^6.6.3: dependencies: tslib "^1.9.0" +rxjs@^7.8.0: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.0.tgz#8d0cae9cb806d6d1c06e08ab13d847293ebe0692" @@ -15023,6 +15054,13 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici@^5.8.1: + version "5.28.3" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.3.tgz#a731e0eff2c3fcfd41c1169a869062be222d1e5b" + integrity sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA== + dependencies: + "@fastify/busboy" "^2.0.0" + unenv@^1.8.0: version "1.9.0" resolved "https://registry.yarnpkg.com/unenv/-/unenv-1.9.0.tgz#469502ae85be1bd3a6aa60f810972b1a904ca312" diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index 5ab661e2d..1c177eeb7 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -124,6 +124,27 @@ public class XMTPModule: Module { throw error } } + + AsyncFunction("sign") { (clientAddress: String, digest: [UInt8], keyType: String, preKeyIndex: Int) -> [UInt8] in + guard let client = await clientsManager.getClient(key: clientAddress) else { + throw Error.noClient + } + let privateKeyBundle = client.keys + let key = keyType == "prekey" ? privateKeyBundle.preKeys[preKeyIndex] : privateKeyBundle.identityKey + + let privateKey = try PrivateKey(key) + let signature = try await privateKey.sign(Data(digest)) + let uint = try [UInt8](signature.serializedData()) + return uint + } + + AsyncFunction("exportPublicKeyBundle") { (clientAddress: String) -> [UInt8] in + guard let client = await clientsManager.getClient(key: clientAddress) else { + throw Error.noClient + } + let bundle = try client.publicKeyBundle.serializedData() + return Array(bundle) + } // Export the client's serialized key bundle. AsyncFunction("exportKeyBundle") { (clientAddress: String) -> String in diff --git a/src/index.ts b/src/index.ts index 7d0e2854f..c560f04b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,29 @@ export async function createFromKeyBundle( ) } +export async function sign( + clientAddress: string, + digest: Uint8Array, + keyType: string, + preKeyIndex: number = 0 +): Promise { + const signatureArray = await XMTPModule.sign( + clientAddress, + Array.from(digest), + keyType, + preKeyIndex + ) + return new Uint8Array(signatureArray) +} + +export async function exportPublicKeyBundle( + clientAddress: string +): Promise { + const publicBundleArray = + await XMTPModule.exportPublicKeyBundle(clientAddress) + return new Uint8Array(publicBundleArray) +} + export async function exportKeyBundle(clientAddress: string): Promise { return await XMTPModule.exportKeyBundle(clientAddress) } diff --git a/src/lib/Client.ts b/src/lib/Client.ts index 4dab22ebd..43b4cd2c7 100644 --- a/src/lib/Client.ts +++ b/src/lib/Client.ts @@ -302,6 +302,19 @@ export class Client { this.codecRegistry[id] = contentCodec } + async sign(digest: Uint8Array, keyType: KeyType): Promise { + return XMTPModule.sign( + this.address, + digest, + keyType.kind, + keyType.prekeyIndex + ) + } + + async exportPublicKeyBundle(): Promise { + return XMTPModule.exportPublicKeyBundle(this.address) + } + /** * Exports the key bundle associated with the current XMTP address. * @@ -408,6 +421,11 @@ export type NetworkOptions = { appVersion?: string } +export type KeyType = { + kind: 'identity' | 'prekey' + prekeyIndex?: number +} + export type CallbackOptions = { preCreateIdentityCallback?: () => Promise | void preEnableIdentityCallback?: () => Promise | void