From 551432c8f2e120556685650ad73da06b55ea3eff Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Fri, 22 Nov 2024 17:49:13 -0600 Subject: [PATCH] Refactor frames validator to support V3 --- packages/frames-validator/src/index.test.ts | 109 --------- packages/frames-validator/src/index.ts | 2 +- .../frames-validator/src/validation.test.ts | 211 ++++++++++++++++++ packages/frames-validator/src/validation.ts | 64 ++++-- .../src/{openFrames.ts => validator.ts} | 4 +- 5 files changed, 263 insertions(+), 127 deletions(-) delete mode 100644 packages/frames-validator/src/index.test.ts create mode 100644 packages/frames-validator/src/validation.test.ts rename packages/frames-validator/src/{openFrames.ts => validator.ts} (91%) diff --git a/packages/frames-validator/src/index.test.ts b/packages/frames-validator/src/index.test.ts deleted file mode 100644 index d61a3f99f..000000000 --- a/packages/frames-validator/src/index.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { FramesClient } from "@xmtp/frames-client"; -import { fetcher, frames } from "@xmtp/proto"; -import { Client, PrivateKeyBundleV2 } from "@xmtp/xmtp-js"; -import { Wallet } from "ethers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { deserializeProtoMessage, validateFramesPost } from "."; - -const { b64Decode, b64Encode } = fetcher; - -function scrambleBytes(bytes: Uint8Array) { - const scrambled = new Uint8Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - scrambled[i] = bytes[bytes.length - i - 1]; - } - return scrambled; -} - -describe("validations", () => { - let client: Client; - let framesClient: FramesClient; - - const FRAME_URL = "https://frame.xyz"; - const CONVERSATION_TOPIC = "/xmtp/0/1234"; - const PARTICIPANT_ACCOUNT_ADDRESSES = ["0x1234", "0x5678"]; - const BUTTON_INDEX = 2; - - beforeEach(async () => { - const wallet = Wallet.createRandom(); - client = await Client.create(wallet); - framesClient = new FramesClient(client); - }); - - it("succeeds in the happy path", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - const validated = validateFramesPost(postData); - expect(validated.verifiedWalletAddress).toEqual(client.address); - }); - - it("fails if the signature verification fails", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - // Monkey around with the signature - const deserialized = deserializeProtoMessage( - b64Decode(postData.trustedData.messageBytes), - ); - - if (!deserialized.signature.ecdsaCompact?.bytes) { - throw new Error("Signature bytes are empty"); - } - - deserialized.signature.ecdsaCompact.bytes = scrambleBytes( - deserialized.signature.ecdsaCompact.bytes, - ); - const reserialized = frames.FrameAction.encode({ - signature: deserialized.signature, - actionBody: deserialized.actionBodyBytes, - signedPublicKeyBundle: deserialized.signedPublicKeyBundle, - }).finish(); - - postData.trustedData.messageBytes = b64Encode( - reserialized, - 0, - reserialized.length, - ); - - expect(() => validateFramesPost(postData)).toThrow(); - }); - - it("fails if the wallet address doesn't match", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - // Monkey around with the signature - const deserialized = deserializeProtoMessage( - b64Decode(postData.trustedData.messageBytes), - ); - - const throwAwayWallet = Wallet.createRandom(); - const wrongPublicKeyBundle = ( - await PrivateKeyBundleV2.generate(throwAwayWallet) - ).getPublicKeyBundle(); - - const reserialized = frames.FrameAction.encode({ - signature: deserialized.signature, - actionBody: deserialized.actionBodyBytes, - signedPublicKeyBundle: wrongPublicKeyBundle, - }).finish(); - - postData.trustedData.messageBytes = b64Encode( - reserialized, - 0, - reserialized.length, - ); - - expect(() => validateFramesPost(postData)).toThrow(); - }); -}); diff --git a/packages/frames-validator/src/index.ts b/packages/frames-validator/src/index.ts index 9248b3301..10d884381 100644 --- a/packages/frames-validator/src/index.ts +++ b/packages/frames-validator/src/index.ts @@ -1,2 +1,2 @@ -export * from "./openFrames.js"; +export * from "./validator.js"; export * from "./validation.js"; diff --git a/packages/frames-validator/src/validation.test.ts b/packages/frames-validator/src/validation.test.ts new file mode 100644 index 000000000..175434086 --- /dev/null +++ b/packages/frames-validator/src/validation.test.ts @@ -0,0 +1,211 @@ +import { getRandomValues } from "node:crypto"; +import { + FramesClient, + isV3FramesSigner, + type FramesSigner, + type V2FramesSigner, + type V3FramesSigner, +} from "@xmtp/frames-client"; +import { Client as V3Client } from "@xmtp/node-sdk"; +import { fetcher, frames } from "@xmtp/proto"; +import { Client, PrivateKeyBundleV2 } from "@xmtp/xmtp-js"; +import { getBytes, Wallet } from "ethers"; +import { describe, expect, it } from "vitest"; +import { deserializeProtoMessage, validateFramesPost } from "."; + +const { b64Decode, b64Encode } = fetcher; + +function scrambleBytes(bytes: Uint8Array) { + const scrambled = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + scrambled[i] = bytes[bytes.length - i - 1]; + } + return scrambled; +} + +const getV2Setup = async () => { + const client = await Client.create(Wallet.createRandom(), { env: "local" }); + const signer: V2FramesSigner = { + address: () => client.address, + getPublicKeyBundle: () => client.keystore.getPublicKeyBundle(), + sign: (digest: Uint8Array) => + client.keystore.signDigest({ + digest, + identityKey: true, + prekeyIndex: undefined, + }), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const getV3Setup = async () => { + const encryptionKey = getRandomValues(new Uint8Array(32)); + const wallet = Wallet.createRandom(); + const client = await V3Client.create( + { + getAddress: () => wallet.address, + signMessage: async (message: string) => + getBytes(await wallet.signMessage(message)), + }, + encryptionKey, + { env: "local" }, + ); + const signer: V3FramesSigner = { + address: () => client.accountAddress, + installationId: () => client.installationIdBytes, + inboxId: () => client.inboxId, + sign: (digest: Uint8Array) => + client.signWithInstallationKey(Buffer.from(digest).toString("hex")), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const FRAME_URL = "https://frame.xyz"; +const CONVERSATION_TOPIC = "/xmtp/0/1234"; +const PARTICIPANT_ACCOUNT_ADDRESSES = ["0x1234", "0x5678"]; +const BUTTON_INDEX = 2; + +const shouldValidateFramesPost = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const validated = await validateFramesPost(postData, "local"); + expect(validated.verifiedWalletAddress).toEqual(await signer.address()); + }; + +const shouldFailWithInvalidSignature = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const deserialized = deserializeProtoMessage( + b64Decode(postData.trustedData.messageBytes), + ); + + const isV3 = isV3FramesSigner(signer); + + if (isV3) { + deserialized.installationSignature = scrambleBytes( + deserialized.installationSignature, + ); + } else { + if (!deserialized.signature?.ecdsaCompact?.bytes) { + throw new Error("Signature bytes are empty"); + } + + deserialized.signature.ecdsaCompact.bytes = scrambleBytes( + deserialized.signature.ecdsaCompact.bytes, + ); + } + + const reserialized = frames.FrameAction.encode({ + actionBody: deserialized.actionBodyBytes, + signature: isV3 ? undefined : deserialized.signature, + signedPublicKeyBundle: isV3 + ? undefined + : deserialized.signedPublicKeyBundle, + installationSignature: isV3 + ? deserialized.installationSignature + : new Uint8Array(), + installationId: isV3 ? deserialized.installationId : new Uint8Array(), + inboxId: isV3 ? deserialized.inboxId : "", + }).finish(); + + postData.trustedData.messageBytes = b64Encode( + reserialized, + 0, + reserialized.length, + ); + + await expect(() => validateFramesPost(postData, "local")).rejects.toThrow(); + }; + +const shouldFailWithWalletAddressMismatch = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const deserialized = deserializeProtoMessage( + b64Decode(postData.trustedData.messageBytes), + ); + + const isV3 = isV3FramesSigner(signer); + + if (isV3) { + deserialized.inboxId = "wrong-inbox-id"; + } else { + const throwAwayWallet = Wallet.createRandom(); + deserialized.signedPublicKeyBundle = ( + await PrivateKeyBundleV2.generate(throwAwayWallet) + ).getPublicKeyBundle(); + } + + const reserialized = frames.FrameAction.encode({ + actionBody: deserialized.actionBodyBytes, + signature: isV3 ? undefined : deserialized.signature, + signedPublicKeyBundle: isV3 + ? undefined + : deserialized.signedPublicKeyBundle, + installationSignature: isV3 + ? deserialized.installationSignature + : new Uint8Array(), + installationId: isV3 ? deserialized.installationId : new Uint8Array(), + inboxId: isV3 ? deserialized.inboxId : "", + }).finish(); + + postData.trustedData.messageBytes = b64Encode( + reserialized, + 0, + reserialized.length, + ); + + await expect(() => validateFramesPost(postData, "local")).rejects.toThrow(); + }; + +describe("validations", () => { + describe("V2", () => { + it("succeeds in the happy path", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldValidateFramesPost(signer, framesClient)(); + }); + + it("fails if the signature verification fails", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldFailWithInvalidSignature(signer, framesClient)(); + }); + + it("fails if the wallet address doesn't match", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldFailWithWalletAddressMismatch(signer, framesClient)(); + }); + }); + + describe("V3", () => { + it("succeeds in the happy path", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldValidateFramesPost(signer, framesClient)(); + }); + + it("fails if the signature verification fails", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldFailWithInvalidSignature(signer, framesClient)(); + }); + + it("fails if the wallet address doesn't match", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldFailWithWalletAddressMismatch(signer, framesClient)(); + }); + }); +}); diff --git a/packages/frames-validator/src/validation.ts b/packages/frames-validator/src/validation.ts index 895e0dd86..d9713e7f1 100644 --- a/packages/frames-validator/src/validation.ts +++ b/packages/frames-validator/src/validation.ts @@ -1,4 +1,7 @@ +import { sha256 } from "@noble/hashes/sha256"; +import { Client, getInboxIdForAddress, type XmtpEnv } from "@xmtp/node-sdk"; import { fetcher, frames, type publicKey, type signature } from "@xmtp/proto"; +import { uint8ArrayToHex } from "uint8array-extras"; import type { UntrustedData, XmtpOpenFramesRequest, @@ -10,44 +13,70 @@ export type * from "./types.js"; const { b64Decode } = fetcher; -export function validateFramesPost( +export async function validateFramesPost( data: XmtpOpenFramesRequest, -): XmtpValidationResponse { + env?: XmtpEnv, +): Promise { const { untrustedData, trustedData } = data; const { walletAddress } = untrustedData; const { messageBytes: messageBytesString } = trustedData; const messageBytes = b64Decode(messageBytesString); - const { actionBody, actionBodyBytes, signature, signedPublicKeyBundle } = - deserializeProtoMessage(messageBytes); - - const verifiedWalletAddress = getVerifiedWalletAddress( + const { + actionBody, actionBodyBytes, signature, signedPublicKeyBundle, - ); + installationId, // not necessary + installationSignature, + inboxId, + } = deserializeProtoMessage(messageBytes); + + const isV2Frame = signature && signedPublicKeyBundle; + + if (isV2Frame) { + const verifiedWalletAddress = getVerifiedWalletAddress( + actionBodyBytes, + signature, + signedPublicKeyBundle, + ); - if (verifiedWalletAddress !== walletAddress) { - console.log(`${verifiedWalletAddress} !== ${walletAddress}`); - throw new Error("Invalid wallet address"); + if (verifiedWalletAddress !== walletAddress) { + console.log(`${verifiedWalletAddress} !== ${walletAddress}`); + throw new Error("Invalid wallet address"); + } + } else { + // make sure inbox IDs match + const addressInboxId = await getInboxIdForAddress(walletAddress, env); + if (inboxId !== addressInboxId) { + throw new Error("Invalid inbox ID"); + } + + const digest = sha256(actionBodyBytes); + + // make sure installation signature is valid + const valid = Client.verifySignedWithPublicKey( + uint8ArrayToHex(digest), + installationSignature, + installationId, + ); + + if (!valid) { + throw new Error("Invalid signature"); + } } checkUntrustedData(untrustedData, actionBody); return { actionBody, - verifiedWalletAddress, + verifiedWalletAddress: walletAddress, }; } export function deserializeProtoMessage(messageBytes: Uint8Array) { const frameAction = frames.FrameAction.decode(messageBytes); - if (!frameAction.signature || !frameAction.signedPublicKeyBundle) { - throw new Error( - "Invalid frame action: missing signature or signed public key bundle", - ); - } const actionBody = frames.FrameActionBody.decode(frameAction.actionBody); return { @@ -55,6 +84,9 @@ export function deserializeProtoMessage(messageBytes: Uint8Array) { actionBodyBytes: frameAction.actionBody, signature: frameAction.signature, signedPublicKeyBundle: frameAction.signedPublicKeyBundle, + installationId: frameAction.installationId, + installationSignature: frameAction.installationSignature, + inboxId: frameAction.inboxId, }; } diff --git a/packages/frames-validator/src/openFrames.ts b/packages/frames-validator/src/validator.ts similarity index 91% rename from packages/frames-validator/src/openFrames.ts rename to packages/frames-validator/src/validator.ts index 606aab5a0..835b2a498 100644 --- a/packages/frames-validator/src/openFrames.ts +++ b/packages/frames-validator/src/validator.ts @@ -3,6 +3,7 @@ import type { RequestValidator, ValidationResponse, } from "@open-frames/types"; +import type { XmtpEnv } from "@xmtp/node-sdk"; import type { XmtpOpenFramesRequest, XmtpValidationResponse } from "./types"; import { validateFramesPost } from "./validation"; @@ -37,11 +38,12 @@ export class XmtpValidator async validate( payload: XmtpOpenFramesRequest, + env?: XmtpEnv, ): Promise< ValidationResponse > { try { - const validationResponse = validateFramesPost(payload); + const validationResponse = await validateFramesPost(payload, env); return await Promise.resolve({ isValid: true, clientProtocol: payload.clientProtocol,