diff --git a/content-types/content-type-primitives/package.json b/content-types/content-type-primitives/package.json index 4c3f47aa6..85e74906d 100644 --- a/content-types/content-type-primitives/package.json +++ b/content-types/content-type-primitives/package.json @@ -68,7 +68,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "happy-dom": "^15.7.4", "rimraf": "^6.0.1", "rollup": "^4.24.0", diff --git a/content-types/content-type-reaction/package.json b/content-types/content-type-reaction/package.json index 3eedda353..ecaddd25d 100644 --- a/content-types/content-type-reaction/package.json +++ b/content-types/content-type-reaction/package.json @@ -68,7 +68,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", "ethers": "^6.11.1", diff --git a/content-types/content-type-read-receipt/package.json b/content-types/content-type-read-receipt/package.json index 78df7fdd9..23fe614d8 100644 --- a/content-types/content-type-read-receipt/package.json +++ b/content-types/content-type-read-receipt/package.json @@ -68,7 +68,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", "ethers": "^6.11.1", diff --git a/content-types/content-type-remote-attachment/package.json b/content-types/content-type-remote-attachment/package.json index 11de444c8..c27e7b185 100644 --- a/content-types/content-type-remote-attachment/package.json +++ b/content-types/content-type-remote-attachment/package.json @@ -70,7 +70,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/rollup-plugin-resolve-extensions": "^1.0.1", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", diff --git a/content-types/content-type-reply/package.json b/content-types/content-type-reply/package.json index 132bc7712..cc667141a 100644 --- a/content-types/content-type-reply/package.json +++ b/content-types/content-type-reply/package.json @@ -69,7 +69,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/content-type-remote-attachment": "workspace:*", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", diff --git a/content-types/content-type-text/package.json b/content-types/content-type-text/package.json index 088540983..3678d580d 100644 --- a/content-types/content-type-text/package.json +++ b/content-types/content-type-text/package.json @@ -68,7 +68,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", "ethers": "^6.11.1", diff --git a/content-types/content-type-transaction-reference/package.json b/content-types/content-type-transaction-reference/package.json index eb04bb628..38aa75a37 100644 --- a/content-types/content-type-transaction-reference/package.json +++ b/content-types/content-type-transaction-reference/package.json @@ -68,7 +68,7 @@ "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@xmtp/xmtp-js": "^11.6.3", "buffer": "^6.0.3", "ethers": "^6.11.1", diff --git a/package.json b/package.json index 700d6a4df..e5a3937aa 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@eslint/js": "^9.12.0", "@ianvs/prettier-plugin-sort-imports": "^4.3.1", "@types/eslint__js": "^8.42.3", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "eslint": "^9.12.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", diff --git a/packages/frames-validator/CHANGELOG.md b/packages/frames-validator/CHANGELOG.md new file mode 100644 index 000000000..5379c5322 --- /dev/null +++ b/packages/frames-validator/CHANGELOG.md @@ -0,0 +1,73 @@ +# @xmtp/frames-validator + +## 0.6.2 + +### Patch Changes + +- [#260](https://github.com/xmtp/xmtp-node-js-tools/pull/260) [`57bf55d`](https://github.com/xmtp/xmtp-node-js-tools/commit/57bf55d89bce8a52a1dfaf8b7fc649054aaa6fd5) Thanks [@neekolas](https://github.com/neekolas)! - Update dependencies + +## 0.6.1 + +### Patch Changes + +- [#232](https://github.com/xmtp/xmtp-node-js-tools/pull/232) [`15c5032`](https://github.com/xmtp/xmtp-node-js-tools/commit/15c50320b06a80e50d666fa36da201cc754d3d68) Thanks [@daria-github](https://github.com/daria-github)! - Bumped version of proto package. + +## 0.6.0 + +### Minor Changes + +- [#191](https://github.com/xmtp/xmtp-node-js-tools/pull/191) [`da721b9`](https://github.com/xmtp/xmtp-node-js-tools/commit/da721b981ba7b225345c7086952f343592796992) Thanks [@alexrisch](https://github.com/alexrisch)! - Added State handling + +## 0.5.2 + +### Patch Changes + +- [#169](https://github.com/xmtp/xmtp-node-js-tools/pull/169) [`ea52fb6`](https://github.com/xmtp/xmtp-node-js-tools/commit/ea52fb63562d611307c7005c8fba472bc286e7e7) Thanks [@neekolas](https://github.com/neekolas)! - Add state field + +## 0.5.1 + +### Patch Changes + +- [#161](https://github.com/xmtp/xmtp-node-js-tools/pull/161) [`0c3cbb8`](https://github.com/xmtp/xmtp-node-js-tools/commit/0c3cbb8fb3aa392ec72787e1512d177c7c49a011) Thanks [@neekolas](https://github.com/neekolas)! - Upgrade xmtp proto + +## 0.5.0 + +### Minor Changes + +- [#154](https://github.com/xmtp/xmtp-node-js-tools/pull/154) [`7530777`](https://github.com/xmtp/xmtp-node-js-tools/commit/7530777be8e863a87bc5cad6136db8202eb9bea7) Thanks [@neekolas](https://github.com/neekolas)! - Switch out encryption library for better commonjs support + +## 0.4.0 + +### Minor Changes + +- [#147](https://github.com/xmtp/xmtp-node-js-tools/pull/147) [`9ad92d8`](https://github.com/xmtp/xmtp-node-js-tools/commit/9ad92d801ce58a0610078016640a4e611b73e662) Thanks [@neekolas](https://github.com/neekolas)! - Adds support for an Open Frames validator + +## 0.3.1 + +### Patch Changes + +- [#145](https://github.com/xmtp/xmtp-node-js-tools/pull/145) [`5fb6232`](https://github.com/xmtp/xmtp-node-js-tools/commit/5fb623267505a3e964281e3527c76c6a1c752c14) Thanks [@neekolas](https://github.com/neekolas)! - Export all the types + +## 0.3.0 + +### Minor Changes + +- [#143](https://github.com/xmtp/xmtp-node-js-tools/pull/143) [`050c529`](https://github.com/xmtp/xmtp-node-js-tools/commit/050c52986414773dba01796ed86d1ea5ec365be8) Thanks [@neekolas](https://github.com/neekolas)! - Configure to export for both Node.js and ESM + +## 0.2.0 + +### Minor Changes + +- [#140](https://github.com/xmtp/xmtp-node-js-tools/pull/140) [`4010423`](https://github.com/xmtp/xmtp-node-js-tools/commit/40104235bb8f5ab62cd98e35214d62e268816c93) Thanks [@neekolas](https://github.com/neekolas)! - Update to latest version of our protos + +## 0.1.1 + +### Patch Changes + +- [#133](https://github.com/xmtp/xmtp-node-js-tools/pull/133) [`ee73b40`](https://github.com/xmtp/xmtp-node-js-tools/commit/ee73b40f72f22d62bd3d341ce691cc30e18c3ec3) Thanks [@neekolas](https://github.com/neekolas)! - Fix import error + +## 0.1.0 + +### Minor Changes + +- [#131](https://github.com/xmtp/xmtp-node-js-tools/pull/131) [`03a6083`](https://github.com/xmtp/xmtp-node-js-tools/commit/03a608352ec9814edda449ad75610a78ad6c4110) Thanks [@neekolas](https://github.com/neekolas)! - Initialize frames-validator package diff --git a/packages/frames-validator/README.md b/packages/frames-validator/README.md new file mode 100644 index 000000000..3e6713716 --- /dev/null +++ b/packages/frames-validator/README.md @@ -0,0 +1,20 @@ +# Frames Validator + +A set of tools for validating POST payloads from XMTP Frames + +## Usage + +```ts +import { validateFramesPost } from "@xmtp/frames-validator" + +export function handler(requestBody: any) { + // This is an XMTP payload + if (requestBody.untrustedData?.clientType === "xmtp") { + const { verifiedWalletAddress } = await validateFramesPost(requestBody) + return doSomethingWithWalletAddress(verifiedWalletAddress) + } else { + // This is a Farcaster POST payload + return doSomethingWithFarcasterPayload(requestBody) + } +} +``` diff --git a/packages/frames-validator/package.json b/packages/frames-validator/package.json new file mode 100644 index 000000000..e0960c0cb --- /dev/null +++ b/packages/frames-validator/package.json @@ -0,0 +1,57 @@ +{ + "name": "@xmtp/frames-validator", + "version": "0.6.2", + "description": "A validator for XMTP frames requests", + "homepage": "https://github.com/xmtp/xmtp-node-js-tools#readme", + "bugs": { + "url": "https://github.com/xmtp/xmtp-node-js-tools/issues" + }, + "license": "MIT", + "author": "XMTP Labs ", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "yarn clean:dist && yarn rollup -c", + "clean": "rm -rf .turbo && rm -rf node_modules && yarn clean:dist", + "clean:dist": "rm -rf dist", + "test": "vitest run", + "typecheck": "tsc" + }, + "dependencies": { + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.4.0", + "@xmtp/proto": "3.61.1", + "viem": "^2.16.5" + }, + "devDependencies": { + "@open-frames/types": "^0.1.1", + "@rollup/plugin-typescript": "^12.1.1", + "@xmtp/frames-client": "^0.5.4", + "@xmtp/xmtp-js": "^12.1.0", + "ethers": "^6.10.0", + "rollup": "^4.24.0", + "rollup-plugin-dts": "^6.1.1", + "typescript": "^5.6.3", + "vitest": "^2.1.3" + }, + "packageManager": "yarn@4.5.0", + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/frames-validator/rollup.config.js b/packages/frames-validator/rollup.config.js new file mode 100644 index 000000000..10e2dee96 --- /dev/null +++ b/packages/frames-validator/rollup.config.js @@ -0,0 +1,49 @@ +import typescript from "@rollup/plugin-typescript"; +import { defineConfig } from "rollup"; +import { dts } from "rollup-plugin-dts"; + +const external = [ + "@noble/curves/abstract/utils", + "@noble/curves/secp256k1", + "@noble/hashes/sha256", + "@xmtp/proto", + "viem/utils", +]; + +const plugins = [ + typescript({ + declaration: false, + declarationMap: false, + }), +]; + +export default defineConfig([ + { + input: "src/index.ts", + output: { + file: "dist/index.js", + format: "es", + sourcemap: true, + }, + plugins, + external, + }, + { + input: "src/index.ts", + output: { + file: "dist/index.cjs", + format: "cjs", + sourcemap: true, + }, + plugins, + external, + }, + { + input: "src/index.ts", + output: { + file: "dist/index.d.ts", + format: "es", + }, + plugins: [dts()], + }, +]); diff --git a/packages/frames-validator/src/index.test.ts b/packages/frames-validator/src/index.test.ts new file mode 100644 index 000000000..d61a3f99f --- /dev/null +++ b/packages/frames-validator/src/index.test.ts @@ -0,0 +1,109 @@ +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 new file mode 100644 index 000000000..9248b3301 --- /dev/null +++ b/packages/frames-validator/src/index.ts @@ -0,0 +1,2 @@ +export * from "./openFrames.js"; +export * from "./validation.js"; diff --git a/packages/frames-validator/src/openFrames.ts b/packages/frames-validator/src/openFrames.ts new file mode 100644 index 000000000..606aab5a0 --- /dev/null +++ b/packages/frames-validator/src/openFrames.ts @@ -0,0 +1,56 @@ +import type { + OpenFramesRequest, + RequestValidator, + ValidationResponse, +} from "@open-frames/types"; +import type { XmtpOpenFramesRequest, XmtpValidationResponse } from "./types"; +import { validateFramesPost } from "./validation"; + +export class XmtpValidator + implements + RequestValidator +{ + readonly protocolIdentifier = "xmtp"; + readonly minProtocolVersionDate = "2024-02-09"; + + minProtocolVersion(): string { + return `${this.protocolIdentifier}@${this.minProtocolVersionDate}`; + } + + isSupported(payload: OpenFramesRequest): payload is XmtpOpenFramesRequest { + if (!payload.clientProtocol) { + return false; + } + + const [protocol, version] = payload.clientProtocol.split("@"); + if (!protocol || !version) { + return false; + } + + const isCorrectClientProtocol = protocol === "xmtp"; + const isCorrectVersion = version >= this.minProtocolVersionDate; + const isTrustedDataValid = + typeof payload.trustedData.messageBytes === "string"; + + return isCorrectClientProtocol && isCorrectVersion && isTrustedDataValid; + } + + async validate( + payload: XmtpOpenFramesRequest, + ): Promise< + ValidationResponse + > { + try { + const validationResponse = validateFramesPost(payload); + return await Promise.resolve({ + isValid: true, + clientProtocol: payload.clientProtocol, + message: validationResponse, + }); + } catch { + return Promise.resolve({ + isValid: false, + }); + } + } +} diff --git a/packages/frames-validator/src/types.ts b/packages/frames-validator/src/types.ts new file mode 100644 index 000000000..8cd435254 --- /dev/null +++ b/packages/frames-validator/src/types.ts @@ -0,0 +1,21 @@ +import type { + OpenFramesTrustedData, + OpenFramesUntrustedData, +} from "@open-frames/types"; +import type { frames } from "@xmtp/proto"; + +export type UntrustedData = OpenFramesUntrustedData & { + walletAddress: string; // Untrusted version of the wallet address + opaqueConversationIdentifier: string; // A hash of the conversation topic and the participants +}; + +export type XmtpOpenFramesRequest = { + clientProtocol: `xmtp@${string}`; + untrustedData: UntrustedData; + trustedData: OpenFramesTrustedData; +}; + +export type XmtpValidationResponse = { + actionBody: frames.FrameActionBody; + verifiedWalletAddress: string; +}; diff --git a/packages/frames-validator/src/utils.ts b/packages/frames-validator/src/utils.ts new file mode 100644 index 000000000..fb18ae07d --- /dev/null +++ b/packages/frames-validator/src/utils.ts @@ -0,0 +1,155 @@ +import { bytesToHex } from "@noble/curves/abstract/utils"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { sha256 } from "@noble/hashes/sha256"; +import { publicKey, type signature } from "@xmtp/proto"; +import type { SignedPublicKeyBundle } from "@xmtp/xmtp-js"; +import { + getAddress, + hashMessage, + keccak256, + bytesToHex as viemBytesToHex, +} from "viem/utils"; + +export type ECDSACompactWithRecovery = { + bytes: Uint8Array; // compact representation [ R || S ], 64 bytes + recovery: number; // recovery bit +}; + +// hexToBytes implementation that is compatible with `xmtp-js`'s implementation +export function hexToBytes(s: string): Uint8Array { + if (s.startsWith("0x")) { + s = s.slice(2); + } + const bytes = new Uint8Array(s.length / 2); + for (let i = 0; i < bytes.length; i++) { + const j = i * 2; + bytes[i] = Number.parseInt(s.slice(j, j + 2), 16); + } + return bytes; +} + +// Ensure the signature is valid +function ecdsaCheck(sig: ECDSACompactWithRecovery): void { + if (sig.bytes.length !== 64) { + throw new Error(`invalid signature length: ${sig.bytes.length}`); + } + if (sig.recovery !== 0 && sig.recovery !== 1) { + throw new Error(`invalid recovery bit: ${sig.recovery}`); + } +} + +// Get the signature bytes from a Signature proto message, whether it is wallet signed or signed by +// an XMTP key +function extractSignature( + signature: signature.Signature, +): ECDSACompactWithRecovery { + if (signature.ecdsaCompact?.bytes) { + ecdsaCheck(signature.ecdsaCompact); + return signature.ecdsaCompact; + } else if (signature.walletEcdsaCompact?.bytes) { + ecdsaCheck(signature.walletEcdsaCompact); + return signature.walletEcdsaCompact; + } else { + throw new Error("invalid signature"); + } +} + +// Directly copied from `xmtp-js` +function walletSignatureText(keyBytes: Uint8Array): string { + return ( + "XMTP : Create Identity\n" + + `${bytesToHex(keyBytes)}\n` + + "\n" + + "For more info: https://xmtp.org/signatures/" + ); +} + +// Ensure that the `SignedPublicKeyBundle` has the required fields +function validateSignedPublicKeyBundle( + bundle: publicKey.SignedPublicKeyBundle, +): bundle is SignedPublicKeyBundle { + if (!bundle.identityKey?.keyBytes) { + return false; + } + if (!bundle.preKey?.keyBytes) { + return false; + } + return true; +} + +/** + * Validate that a message was signed by the identity key in a `SignedPublicKeyBundle` + * @param message Uint8array + * @param sig signature.Signature + * @param bundle publicKey.SignedPublicKeyBundle + */ +export function verifyIdentityKeySignature( + message: Uint8Array, + sig: signature.Signature, + bundle: publicKey.SignedPublicKeyBundle, +) { + if (!validateSignedPublicKeyBundle(bundle)) { + throw new Error("Invalid public key bundle"); + } + if (!sig.ecdsaCompact?.bytes) { + throw new Error("Missing ECDSA compact"); + } + const pubKey = publicKey.UnsignedPublicKey.decode( + bundle.identityKey.keyBytes, + ); + if (!pubKey.secp256k1Uncompressed?.bytes) { + throw new Error("Missing key bytes"); + } + + const digest = sha256(message); + const isVerified = secp256k1.verify( + sig.ecdsaCompact.bytes, + digest, + pubKey.secp256k1Uncompressed.bytes, + ); + if (!isVerified) { + throw new Error("Invalid signature"); + } +} + +function computeAddress(bytes: Uint8Array) { + const publicKey = viemBytesToHex(bytes.slice(1)); + const hash = keccak256(publicKey); + const address = hash.substring(hash.length - 40); + return getAddress(`0x${address}`); +} + +function recoverWalletAddress( + messageString: string, + sig: ECDSACompactWithRecovery, +) { + const digest = hexToBytes(hashMessage(messageString)); + const pubKey = secp256k1.Signature.fromCompact(sig.bytes) + .addRecoveryBit(sig.recovery) + .recoverPublicKey(digest) + .toRawBytes(false); + + return computeAddress(pubKey); +} + +/** + * Retrieve the wallet address from a `SignedPublicKeyBundle` proto + * @param publicKeyBundle + * @returns string wallet address + */ +export function verifyWalletSignature( + publicKeyBundle: publicKey.SignedPublicKeyBundle, +) { + if (!validateSignedPublicKeyBundle(publicKeyBundle)) { + throw new Error("Invalid public key bundle"); + } + const toVerify = extractSignature(publicKeyBundle.identityKey.signature); + + const signatureText = walletSignatureText( + publicKeyBundle.identityKey.keyBytes, + ); + + const walletAddress = recoverWalletAddress(signatureText, toVerify); + + return walletAddress; +} diff --git a/packages/frames-validator/src/validation.ts b/packages/frames-validator/src/validation.ts new file mode 100644 index 000000000..895e0dd86 --- /dev/null +++ b/packages/frames-validator/src/validation.ts @@ -0,0 +1,108 @@ +import { fetcher, frames, type publicKey, type signature } from "@xmtp/proto"; +import type { + UntrustedData, + XmtpOpenFramesRequest, + XmtpValidationResponse, +} from "./types.js"; +import { verifyIdentityKeySignature, verifyWalletSignature } from "./utils.js"; + +export type * from "./types.js"; + +const { b64Decode } = fetcher; + +export function validateFramesPost( + data: XmtpOpenFramesRequest, +): XmtpValidationResponse { + 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( + actionBodyBytes, + signature, + signedPublicKeyBundle, + ); + + if (verifiedWalletAddress !== walletAddress) { + console.log(`${verifiedWalletAddress} !== ${walletAddress}`); + throw new Error("Invalid wallet address"); + } + + checkUntrustedData(untrustedData, actionBody); + + return { + actionBody, + verifiedWalletAddress, + }; +} + +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 { + actionBody, + actionBodyBytes: frameAction.actionBody, + signature: frameAction.signature, + signedPublicKeyBundle: frameAction.signedPublicKeyBundle, + }; +} + +function getVerifiedWalletAddress( + actionBodyBytes: Uint8Array, + signature: signature.Signature, + signedPublicKeyBundle: publicKey.SignedPublicKeyBundle, +): string { + const walletAddress = verifyWalletSignature(signedPublicKeyBundle); + verifyIdentityKeySignature(actionBodyBytes, signature, signedPublicKeyBundle); + + return walletAddress; +} + +function checkUntrustedData( + { + url, + buttonIndex, + opaqueConversationIdentifier, + timestamp, + state = "", + inputText = "", + }: UntrustedData, + actionBody: frames.FrameActionBody, +) { + if (actionBody.frameUrl !== url) { + throw new Error("Mismatched URL"); + } + + if (actionBody.buttonIndex !== buttonIndex) { + throw new Error("Mismatched button index"); + } + + if ( + actionBody.opaqueConversationIdentifier !== opaqueConversationIdentifier + ) { + throw new Error("Mismatched conversation identifier"); + } + + if (actionBody.timestamp.toNumber() !== timestamp) { + throw new Error("Mismatched timestamp"); + } + + if (actionBody.state !== state) { + throw new Error("Mismatched state"); + } + + if (actionBody.inputText !== inputText) { + throw new Error("Missing input text"); + } +} diff --git a/packages/frames-validator/tsconfig.json b/packages/frames-validator/tsconfig.json new file mode 100644 index 000000000..016c95fe6 --- /dev/null +++ b/packages/frames-validator/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "tsconfig/base.json", + "includes": ["src", "rollup.config.js", "vitest.config.ts", "vitest.setup.ts"] +} diff --git a/packages/frames-validator/vitest.config.ts b/packages/frames-validator/vitest.config.ts new file mode 100644 index 000000000..d01c48c56 --- /dev/null +++ b/packages/frames-validator/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, mergeConfig } from "vite"; +import { defineConfig as defineVitestConfig } from "vitest/config"; + +// https://vitejs.dev/config/ +const viteConfig = defineConfig({ + plugins: [], +}); + +const vitestConfig = defineVitestConfig({ + test: { + globals: true, + environment: "node", + }, +}); + +export default mergeConfig(viteConfig, vitestConfig); diff --git a/sdks/js-sdk/package.json b/sdks/js-sdk/package.json index 69c1fe080..393d004c3 100644 --- a/sdks/js-sdk/package.json +++ b/sdks/js-sdk/package.json @@ -113,7 +113,7 @@ "@types/bl": "^5.1.0", "@types/callback-to-async-iterator": "^1.1.7", "@types/elliptic": "^6.4.18", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@vitest/coverage-v8": "^2.1.3", "@xmtp/rollup-plugin-resolve-extensions": "1.0.1", "benny": "^3.7.1", diff --git a/sdks/node-sdk/package.json b/sdks/node-sdk/package.json index 95ce99c5f..48dd508b2 100644 --- a/sdks/node-sdk/package.json +++ b/sdks/node-sdk/package.json @@ -56,7 +56,7 @@ "devDependencies": { "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-typescript": "^12.1.1", - "@types/node": "^20.16.11", + "@types/node": "^20.16.12", "@vitest/coverage-v8": "^2.1.3", "@xmtp/xmtp-js": "workspace:^", "fast-glob": "^3.3.2", diff --git a/turbo.json b/turbo.json index 52268248f..e50811ac8 100644 --- a/turbo.json +++ b/turbo.json @@ -41,6 +41,13 @@ "@xmtp/content-type-text#build" ], "outputs": [] + }, + "@xmtp/frames-validator#test": { + "dependsOn": [ + "@xmtp/content-type-text#build", + "@xmtp/frames-client#build" + ], + "outputs": [] } } } diff --git a/yarn.lock b/yarn.lock index f82b15f82..26f3a0909 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1511,7 +1511,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:1.6.0, @noble/curves@npm:^1.4.0, @noble/curves@npm:~1.6.0": +"@noble/curves@npm:1.6.0, @noble/curves@npm:^1.3.0, @noble/curves@npm:^1.4.0, @noble/curves@npm:~1.6.0": version: 1.6.0 resolution: "@noble/curves@npm:1.6.0" dependencies: @@ -2286,7 +2286,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.16.11": +"@types/node@npm:^20.16.12": version: 20.16.12 resolution: "@types/node@npm:20.16.12" dependencies: @@ -2569,7 +2569,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/proto": "npm:^3.61.1" happy-dom: "npm:^15.7.4" rimraf: "npm:^6.0.1" @@ -2588,7 +2588,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.2" "@xmtp/xmtp-js": "npm:^11.6.3" buffer: "npm:^6.0.3" @@ -2610,7 +2610,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.2" "@xmtp/xmtp-js": "npm:^11.6.3" buffer: "npm:^6.0.3" @@ -2633,7 +2633,7 @@ __metadata: "@noble/secp256k1": "npm:^1.7.1" "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.2" "@xmtp/proto": "npm:^3.61.1" "@xmtp/rollup-plugin-resolve-extensions": "npm:^1.0.1" @@ -2657,7 +2657,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.2" "@xmtp/content-type-remote-attachment": "workspace:*" "@xmtp/proto": "npm:^3.61.1" @@ -2681,7 +2681,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.1" "@xmtp/xmtp-js": "npm:^11.6.3" buffer: "npm:^6.0.3" @@ -2703,7 +2703,7 @@ __metadata: dependencies: "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@xmtp/content-type-primitives": "npm:^1.0.1" "@xmtp/xmtp-js": "npm:^11.6.3" buffer: "npm:^6.0.3" @@ -2719,7 +2719,7 @@ __metadata: languageName: unknown linkType: soft -"@xmtp/frames-client@workspace:packages/frames-client": +"@xmtp/frames-client@npm:^0.5.4, @xmtp/frames-client@workspace:packages/frames-client": version: 0.0.0-use.local resolution: "@xmtp/frames-client@workspace:packages/frames-client" dependencies: @@ -2747,6 +2747,26 @@ __metadata: languageName: unknown linkType: soft +"@xmtp/frames-validator@workspace:packages/frames-validator": + version: 0.0.0-use.local + resolution: "@xmtp/frames-validator@workspace:packages/frames-validator" + dependencies: + "@noble/curves": "npm:^1.3.0" + "@noble/hashes": "npm:^1.4.0" + "@open-frames/types": "npm:^0.1.1" + "@rollup/plugin-typescript": "npm:^12.1.1" + "@xmtp/frames-client": "npm:^0.5.4" + "@xmtp/proto": "npm:3.61.1" + "@xmtp/xmtp-js": "npm:^12.1.0" + ethers: "npm:^6.10.0" + rollup: "npm:^4.24.0" + rollup-plugin-dts: "npm:^6.1.1" + typescript: "npm:^5.6.3" + viem: "npm:^2.16.5" + vitest: "npm:^2.1.3" + languageName: unknown + linkType: soft + "@xmtp/node-bindings@npm:^0.0.13": version: 0.0.13 resolution: "@xmtp/node-bindings@npm:0.0.13" @@ -2760,7 +2780,7 @@ __metadata: dependencies: "@rollup/plugin-json": "npm:^6.1.0" "@rollup/plugin-typescript": "npm:^12.1.1" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@vitest/coverage-v8": "npm:^2.1.3" "@xmtp/content-type-primitives": "npm:^1.0.1" "@xmtp/content-type-text": "npm:^1.0.0" @@ -2793,6 +2813,18 @@ __metadata: languageName: node linkType: hard +"@xmtp/proto@npm:3.61.1": + version: 3.61.1 + resolution: "@xmtp/proto@npm:3.61.1" + dependencies: + long: "npm:^5.2.0" + protobufjs: "npm:^7.0.0" + rxjs: "npm:^7.8.0" + undici: "npm:^5.8.1" + checksum: 10/c5acae46ad301a50652f30384be55a3389b4c11994652fa5386052c7ff4111fcb15c0e9d267898d6173cbcb6559b24bd7bef7470f020388f8610fecd3b8deea9 + languageName: node + linkType: hard + "@xmtp/proto@npm:3.62.1": version: 3.62.1 resolution: "@xmtp/proto@npm:3.62.1" @@ -2853,7 +2885,7 @@ __metadata: languageName: node linkType: hard -"@xmtp/xmtp-js@npm:^12.0.0": +"@xmtp/xmtp-js@npm:^12.0.0, @xmtp/xmtp-js@npm:^12.1.0": version: 12.1.0 resolution: "@xmtp/xmtp-js@npm:12.1.0" dependencies: @@ -2884,7 +2916,7 @@ __metadata: "@types/bl": "npm:^5.1.0" "@types/callback-to-async-iterator": "npm:^1.1.7" "@types/elliptic": "npm:^6.4.18" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" "@vitest/coverage-v8": "npm:^2.1.3" "@xmtp/consent-proof-signature": "npm:^0.1.3" "@xmtp/content-type-primitives": "npm:^1.0.1" @@ -4198,7 +4230,7 @@ __metadata: languageName: node linkType: hard -"ethers@npm:^6.11.1, ethers@npm:^6.13.1": +"ethers@npm:^6.10.0, ethers@npm:^6.11.1, ethers@npm:^6.13.1": version: 6.13.4 resolution: "ethers@npm:6.13.4" dependencies: @@ -7804,6 +7836,28 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.16.5": + version: 2.21.29 + resolution: "viem@npm:2.21.29" + dependencies: + "@adraffy/ens-normalize": "npm:1.11.0" + "@noble/curves": "npm:1.6.0" + "@noble/hashes": "npm:1.5.0" + "@scure/bip32": "npm:1.5.0" + "@scure/bip39": "npm:1.4.0" + abitype: "npm:1.0.6" + isows: "npm:1.0.6" + webauthn-p256: "npm:0.0.10" + ws: "npm:8.18.0" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/5f50833efe873860ed5bc67e609494447c9b85aef894905889e0292ef7051490ebd6640f8854e89ed76c218887cb8706cd37feb701046c285374f3fcc6a26867 + languageName: node + linkType: hard + "vite-node@npm:2.1.3": version: 2.1.3 resolution: "vite-node@npm:2.1.3" @@ -8141,7 +8195,7 @@ __metadata: "@eslint/js": "npm:^9.12.0" "@ianvs/prettier-plugin-sort-imports": "npm:^4.3.1" "@types/eslint__js": "npm:^8.42.3" - "@types/node": "npm:^20.16.11" + "@types/node": "npm:^20.16.12" eslint: "npm:^9.12.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1"