diff --git a/src/components/Button.tsx b/src/components/Button.tsx index b3da3077..e218aec1 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,4 +1,4 @@ -import { type HtmlEscapedString } from 'hono/utils/html' +import type { HtmlEscapedString } from 'hono/utils/html' export type ButtonProps = { children: string diff --git a/src/dev/routes.tsx b/src/dev/routes.tsx index 4a037659..6aa51622 100644 --- a/src/dev/routes.tsx +++ b/src/dev/routes.tsx @@ -5,7 +5,7 @@ import { validator } from 'hono/validator' import { type FarcBase } from '../farc-base.js' import { parsePath } from '../utils/parsePath.js' -import { Preview, Scripts, Styles, type PreviewProps } from './components.js' +import { Preview, type PreviewProps, Scripts, Styles } from './components.js' import { fetchFrame, getCodeHtml, diff --git a/src/farc-base.tsx b/src/farc-base.tsx index b27d0663..87844c18 100644 --- a/src/farc-base.tsx +++ b/src/farc-base.tsx @@ -27,7 +27,13 @@ export type FarcConstructorParameters< > = { basePath?: basePath | string | undefined honoOptions?: HonoOptions | undefined + hubApiUrl?: string | undefined initialState?: state | undefined + verify?: boolean | undefined +} + +export type FrameOptions = { + verify?: boolean } export type FrameHandlerReturnType = { @@ -47,17 +53,25 @@ export class FarcBase< basePath: string hono: Hono + hubApiUrl = 'https://api.hub.wevm.dev' fetch: Hono['fetch'] get: Hono['get'] post: Hono['post'] + // TODO: default to `true` once devtools has auth. + verify: boolean = process.env.NODE_ENV !== 'development' constructor({ basePath, honoOptions, + hubApiUrl, initialState, + verify, }: FarcConstructorParameters = {}) { this.hono = new Hono(honoOptions) if (basePath) this.hono = this.hono.basePath(basePath) + if (hubApiUrl) this.hubApiUrl = hubApiUrl + if (typeof verify !== 'undefined') this.verify = verify + this.basePath = basePath ?? '/' this.fetch = this.hono.fetch.bind(this.hono) this.get = this.hono.get.bind(this.hono) @@ -71,13 +85,19 @@ export class FarcBase< handler: ( context: FrameContext, ) => FrameHandlerReturnType | Promise, + options: FrameOptions = {}, ) { + const { verify = this.verify } = options + // Frame Route (implements GET & POST). this.hono.use(parsePath(path), async (c) => { const url = new URL(c.req.url) const context = await getFrameContext({ - context: await requestToContext(c.req), + context: await requestToContext(c.req, { + hubApiUrl: this.hubApiUrl, + verify, + }), initialState: this.#initialState, request: c.req, }) diff --git a/src/farc.tsx b/src/farc.tsx index 36b60741..4a8fafc8 100644 --- a/src/farc.tsx +++ b/src/farc.tsx @@ -1,7 +1,11 @@ import { type Env, type Schema } from 'hono' import { routes as devRoutes } from './dev/routes.js' -import { FarcBase, type FrameHandlerReturnType } from './farc-base.js' +import { + FarcBase, + type FrameHandlerReturnType, + type FrameOptions, +} from './farc-base.js' import { type FrameContext } from './types.js' export class Farc< @@ -15,8 +19,9 @@ export class Farc< handler: ( context: FrameContext, ) => FrameHandlerReturnType | Promise, + options: FrameOptions = {}, ) { - super.frame(path, handler) + super.frame(path, handler, options) devRoutes(this, path) } diff --git a/src/types.ts b/src/types.ts index 9ba6acac..7873539d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,7 @@ export type FrameContext = { * - `response` - The frame has been interacted with (user presses button). */ status: 'initial' | 'redirect' | 'response' + verified: boolean url: Context['req']['url'] } diff --git a/src/utils/getFrameContext.ts b/src/utils/getFrameContext.ts index 54cbbbe8..7d2f9523 100644 --- a/src/utils/getFrameContext.ts +++ b/src/utils/getFrameContext.ts @@ -13,6 +13,7 @@ type GetFrameContextParameters = { | 'frameData' | 'status' | 'url' + | 'verified' > initialState?: state request: Context['req'] @@ -22,7 +23,7 @@ export async function getFrameContext( options: GetFrameContextParameters, ): Promise> { const { context, request } = options - const { frameData, initialUrl, previousIntentData } = context || {} + const { frameData, initialUrl, previousIntentData, verified } = context || {} const { buttonValue, inputText, redirect, reset } = getIntentState( frameData, @@ -58,5 +59,6 @@ export async function getFrameContext( request, status, url, + verified, } } diff --git a/src/utils/requestToContext.ts b/src/utils/requestToContext.ts index 6b8f6478..e4322130 100644 --- a/src/utils/requestToContext.ts +++ b/src/utils/requestToContext.ts @@ -2,6 +2,12 @@ import type { Context } from 'hono' import type { FrameContext } from '../types.js' import { fromQuery } from './fromQuery.js' import { parsePath } from './parsePath.js' +import { verifyFrame } from './verifyFrame.js' + +type RequestToContextOptions = { + hubApiUrl: string + verify?: boolean +} type RequestToContextReturnType = Pick< FrameContext, @@ -11,23 +17,40 @@ type RequestToContextReturnType = Pick< | 'frameData' | 'status' | 'url' + | 'verified' > export async function requestToContext( request: Context['req'], + { hubApiUrl, verify = true }: RequestToContextOptions, ): Promise> { - const { trustedData: _trustedData, untrustedData } = + const { trustedData, untrustedData } = (await request.json().catch(() => {})) || {} const { initialUrl, previousState, previousIntentData } = fromQuery< FrameContext >(request.query()) + const message = await (() => { + if (!verify) return + if (!trustedData) return + return verifyFrame({ + hubApiUrl, + trustedData, + url: request.url, + }).catch((err) => { + if (verify) throw err + }) + })() + + const frameData = message?.data?.frameActionBody ?? untrustedData + return { initialUrl: initialUrl ? initialUrl : parsePath(request.url), previousState, previousIntentData, - frameData: untrustedData, + frameData, status: request.method === 'POST' ? 'response' : 'initial', url: request.url, + verified: Boolean(message), } } diff --git a/src/utils/verifyFrame.test.ts b/src/utils/verifyFrame.test.ts new file mode 100644 index 00000000..a5180600 --- /dev/null +++ b/src/utils/verifyFrame.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from 'vitest' +import { verifyFrame } from './verifyFrame.js' + +test('valid', async () => { + const messageBytes = + '0a4f080d10ff2f18c1a6802f20018201400a2168747470733a2f2f746573742d66617263362e76657263656c2e6170702f61706910011a1908ff2f1214000000000000000000000000000000000000000112141de03010b0ce4f39ba4b8ff29851d0d610dc5ddd180122404aab47af096150fe7193713722bcdd6ddcd6cd35c1e84cc42e7713624916a97568fa8232e2ffd70ce5eeafb0391c7bbcdf6c5ba15a9a02834102b016058e7d0128013220daa3f0a5335900f542a266e4b837309aeac52d736f4cf9b2eff0d4c4f4c7e58f' + expect( + await verifyFrame({ + hubApiUrl: 'https://api.hub.wevm.dev', + trustedData: { messageBytes }, + url: 'https://test-farc6.vercel.app/api', + }), + ).toMatchInlineSnapshot(` + { + "data": { + "castAddBody": undefined, + "castRemoveBody": undefined, + "fid": 6143, + "frameActionBody": { + "buttonIndex": 1, + "castId": { + "fid": 6143, + "hash": Uint8Array [ + 211, + 29, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + 77, + 52, + 211, + ], + }, + "inputText": Uint8Array [], + "url": Uint8Array [ + 104, + 116, + 116, + 112, + 115, + 58, + 47, + 47, + 116, + 101, + 115, + 116, + 45, + 102, + 97, + 114, + 99, + 54, + 46, + 118, + 101, + 114, + 99, + 101, + 108, + 46, + 97, + 112, + 112, + 47, + 97, + 112, + 105, + ], + }, + "linkBody": undefined, + "network": 1, + "reactionBody": undefined, + "timestamp": 98571073, + "type": 13, + "userDataBody": undefined, + "usernameProofBody": undefined, + "verificationAddAddressBody": undefined, + "verificationRemoveBody": undefined, + }, + "dataBytes": undefined, + "hash": Uint8Array [ + 211, + 29, + 93, + 123, + 77, + 244, + 215, + 70, + 244, + 113, + 238, + 31, + 223, + 214, + 218, + 225, + 191, + 31, + 127, + 111, + 124, + 231, + 87, + 116, + 119, + 173, + 116, + 117, + 206, + 93, + 117, + ], + "hashScheme": 1, + "signature": Uint8Array [ + 74, + 171, + 71, + 175, + 9, + 97, + 80, + 254, + 113, + 147, + 113, + 55, + 34, + 188, + 221, + 109, + 220, + 214, + 205, + 53, + 193, + 232, + 76, + 196, + 46, + 119, + 19, + 98, + 73, + 22, + 169, + 117, + 104, + 250, + 130, + 50, + 226, + 255, + 215, + 12, + 229, + 238, + 175, + 176, + 57, + 28, + 123, + 188, + 223, + 108, + 91, + 161, + 90, + 154, + 2, + 131, + 65, + 2, + 176, + 22, + 5, + 142, + 125, + 1, + ], + "signatureScheme": 1, + "signer": Uint8Array [ + 211, + 23, + 90, + 107, + 119, + 244, + 107, + 157, + 247, + 231, + 221, + 52, + 127, + 158, + 54, + 107, + 110, + 186, + 123, + 134, + 252, + 223, + 189, + 244, + 245, + 167, + 154, + 115, + 157, + 157, + 239, + 126, + 159, + 225, + 199, + 253, + 111, + 103, + 159, + 127, + 71, + 120, + 115, + 135, + 248, + 115, + 183, + 185, + 241, + ], + } + `) +}) + +test('invalid hash', async () => { + const messageBytes = + '0a4d080d10ff2f18c1a6802f20018201400a2168747470733a2a2f746573742d66617263362e76657263656c2e6170702f61706910011a1908ff2f1214000000000000000000000000000000000000000112141de03010b0ce4f39ba4b8ff29851d0d610dc5ddd180122404aab47af096150fe7193713722bcdd6ddcd6cd35c1e84cc42e7713624916a97568fa8232e2ffd70ce5eeafb0391c7bbcdf6c5ba15a9a02834102b016058e7d0128013220daa3f0a5335900f542a266e4b837309aeac52d736f4cf9b2eff0d4c4f4c7e58f' + await expect(() => + verifyFrame({ + hubApiUrl: 'https://api.hub.wevm.dev', + trustedData: { messageBytes }, + url: 'https://test-farc6.vercel.app/api', + }), + ).rejects.toMatchInlineSnapshot('[Error: message is invalid. invalid hash]') +}) + +test('invalid url', async () => { + const messageBytes = + '0a4f080d10ff2f18c1a6802f20018201400a2168747470733a2f2f746573742d66617263362e76657263656c2e6170702f61706910011a1908ff2f1214000000000000000000000000000000000000000112141de03010b0ce4f39ba4b8ff29851d0d610dc5ddd180122404aab47af096150fe7193713722bcdd6ddcd6cd35c1e84cc42e7713624916a97568fa8232e2ffd70ce5eeafb0391c7bbcdf6c5ba15a9a02834102b016058e7d0128013220daa3f0a5335900f542a266e4b837309aeac52d736f4cf9b2eff0d4c4f4c7e58f' + await expect(() => + verifyFrame({ + hubApiUrl: 'https://api.hub.wevm.dev', + trustedData: { messageBytes }, + url: 'https://test-farc6.vercel.app/foo', + }), + ).rejects.toMatchInlineSnapshot( + '[Error: Invalid frame url: https://test-farc6.vercel.app/api]', + ) +}) diff --git a/src/utils/verifyFrame.ts b/src/utils/verifyFrame.ts new file mode 100644 index 00000000..a45e3bad --- /dev/null +++ b/src/utils/verifyFrame.ts @@ -0,0 +1,39 @@ +import { Message } from '@farcaster/core' +import { hexToBytes } from '@noble/curves/abstract/utils' +import type { TrustedData } from '../types.js' + +export type VerifyFrameParameters = { + fetchOptions?: RequestInit + hubApiUrl: string + trustedData: TrustedData + url: string +} + +export async function verifyFrame({ + fetchOptions, + hubApiUrl, + trustedData, + url, +}: VerifyFrameParameters): Promise { + const body = hexToBytes(trustedData.messageBytes) + const response = await fetch(`${hubApiUrl}/v1/validateMessage`, { + ...fetchOptions, + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + ...fetchOptions?.headers, + }, + body, + }).then((res) => res.json()) + if (!response.valid) + throw new Error(`message is invalid. ${response.details}`) + + const message = Message.fromJSON(response.message) + + const urlBytes = message?.data?.frameActionBody?.url + const frameUrl = urlBytes ? new TextDecoder().decode(urlBytes) : undefined + if (!frameUrl?.startsWith(url)) + throw new Error(`Invalid frame url: ${frameUrl}`) + + return message +}