diff --git a/.changeset/rich-bags-begin.md b/.changeset/rich-bags-begin.md new file mode 100644 index 00000000..490f6c6c --- /dev/null +++ b/.changeset/rich-bags-begin.md @@ -0,0 +1,6 @@ +--- +"frames.js": patch +"@frames.js/debugger": patch +--- + +feat: support for app key signatures diff --git a/packages/frames.js/package.json b/packages/frames.js/package.json index bd62cac2..caf796d1 100644 --- a/packages/frames.js/package.json +++ b/packages/frames.js/package.json @@ -187,6 +187,16 @@ "default": "./dist/farcaster-v2/types.cjs" } }, + "./farcaster-v2/verify": { + "import": { + "types": "./dist/farcaster-v2/verify.d.ts", + "default": "./dist/farcaster-v2/verify.js" + }, + "require": { + "types": "./dist/farcaster-v2/verify.d.cts", + "default": "./dist/farcaster-v2/verify.cjs" + } + }, "./core": { "import": { "types": "./dist/core/index.d.ts", @@ -420,12 +430,14 @@ }, "dependencies": { "@farcaster/frame-core": "^0.0.24", - "@farcaster/frame-node": "^0.0.13", + "@noble/ed25519": "^2.2.3", + "@noble/hashes": "^1.7.1", "@vercel/og": "^0.6.3", "cheerio": "^1.0.0-rc.12", + "ox": "^0.4.4", "protobufjs": "^7.2.6", - "viem": "^2.7.8", "type-fest": "^4.28.1", + "viem": "^2.7.8", "zod": "^3.24.1" } } diff --git a/packages/frames.js/src/farcaster-v2/es25519.ts b/packages/frames.js/src/farcaster-v2/es25519.ts new file mode 100644 index 00000000..4c775d64 --- /dev/null +++ b/packages/frames.js/src/farcaster-v2/es25519.ts @@ -0,0 +1,8 @@ +import { sha512 } from "@noble/hashes/sha512"; +import { etc, getPublicKey, sign, verify } from "@noble/ed25519"; + +if (!etc.sha512Sync) { + etc.sha512Sync = (...m: Uint8Array[]) => sha512(etc.concatBytes(...m)); +} + +export { getPublicKey, sign, verify }; diff --git a/packages/frames.js/src/farcaster-v2/events.ts b/packages/frames.js/src/farcaster-v2/events.ts index 381a5dce..ff820610 100644 --- a/packages/frames.js/src/farcaster-v2/events.ts +++ b/packages/frames.js/src/farcaster-v2/events.ts @@ -3,11 +3,9 @@ import type { EncodedJsonFarcasterSignatureSchema, } from "@farcaster/frame-core"; import { serverEventSchema } from "@farcaster/frame-core"; -import { - createJsonFarcasterSignature, - hexToBytes, -} from "@farcaster/frame-node"; -import type { Hex } from "viem"; +import { bytesToHex, type Hex } from "viem"; +import { sign, signMessageWithAppKey } from "./json-signature"; +import { getPublicKey } from "./es25519"; export class InvalidWebhookResponseError extends Error { constructor( @@ -22,7 +20,7 @@ export type { FrameServerEvent }; type SendEventOptions = { /** - * App private key + * Private app key (signer private key) */ privateKey: Hex | Uint8Array; fid: number; @@ -36,13 +34,16 @@ export async function sendEvent( event: FrameServerEvent, { privateKey, fid, webhookUrl }: SendEventOptions ): Promise { + const appKey = bytesToHex(getPublicKey(privateKey)); const payload = serverEventSchema.parse(event); - const signature = createJsonFarcasterSignature({ + const signature = await sign({ fid, - payload: Buffer.from(JSON.stringify(payload)), - privateKey: - typeof privateKey === "string" ? hexToBytes(privateKey) : privateKey, - type: "app_key", + payload, + signer: { + type: "app_key", + appKey, + }, + signMessage: signMessageWithAppKey(privateKey), }); const response = await fetch(webhookUrl, { @@ -52,11 +53,11 @@ export async function sendEvent( "Content-Type": "application/json", }, body: JSON.stringify( - signature satisfies EncodedJsonFarcasterSignatureSchema + signature.json satisfies EncodedJsonFarcasterSignatureSchema ), }); - if (response.status >= 200 && response.status < 300) { + if (response.ok) { return; } diff --git a/packages/frames.js/src/farcaster-v2/json-signature.test.ts b/packages/frames.js/src/farcaster-v2/json-signature.test.ts index 98d45793..f5cd339d 100644 --- a/packages/frames.js/src/farcaster-v2/json-signature.test.ts +++ b/packages/frames.js/src/farcaster-v2/json-signature.test.ts @@ -1,4 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment -- for expect.any() */ +import { webcrypto } from "node:crypto"; +import * as ed25519 from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha512"; +import type { Hex } from "viem"; +import { bytesToHex, hexToBytes } from "viem"; import { sign, verify, @@ -8,10 +13,23 @@ import { encodeSignature, decodeHeader, decodePayload, - decodeSignature, + decodeCustodyTypeSignature, + decodeAppKeyTypeSignature, constructJSONFarcasterSignatureAccountAssociationPaylod, + signMessageWithAppKey, } from "./json-signature"; +// polyfill for node 18 so we can use randomPrivateKey() +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- not true in node 18 +if (!globalThis.crypto) { + // @ts-expect-error -- this is polyfill + globalThis.crypto = webcrypto; +} + +process.env.NEYNAR_API_KEY = "NEYNAR_FRAMES_JS"; + +ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m)); + const fcDemoSignature = { header: "eyJmaWQiOjM2MjEsInR5cGUiOiJjdXN0b2R5Iiwia2V5IjoiMHgyY2Q4NWEwOTMyNjFmNTkyNzA4MDRBNkVBNjk3Q2VBNENlQkVjYWZFIn0", @@ -33,58 +51,144 @@ const framesJsDemoSignature = { const framesJsDemoCompactSignature = "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ.eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ.MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi"; -const signatures = [framesJsDemoSignature, fcDemoSignature]; +const custodySignatures = [framesJsDemoSignature, fcDemoSignature]; + +const dummyAppKeySignature = { + header: + "eyJmaWQiOjM0MTc5NCwidHlwZSI6ImFwcF9rZXkiLCJrZXkiOiIweGJkZGVhNDQ2ODUxZDYwZjQ4OTAxNjU1NDc4YTIwNTQ3MmNjOTJmNGUwMzdiNTIzNmE1YzVhYmZjMWI4ZTA5MWIifQ", + payload: "eyJ0ZXN0Ijp0cnVlfQ", + signature: + "Y1C9-m6EIAPDqd8-2NrSXBKrpvWKUfA3Qjy865De5yUu7MV_b1TjsQKtwqbaVv_UzFz5ghmvygVbGjhx-kbRDw", +}; + +const dummyAppKeyCompactSignature = `${dummyAppKeySignature.header}.${dummyAppKeySignature.payload}.${dummyAppKeySignature.signature}`; +const appKeySignatures = [dummyAppKeySignature]; -const compactSignatures = [ +const custodyCompactSignatures = [ framesJsDemoCompactSignature, fcDemoCompactSignature, ]; +const appKeyCompactSignatures = [dummyAppKeyCompactSignature]; describe("verifyCompact", () => { - it.each(compactSignatures)("verifies valid message", async (signature) => { - await expect(verifyCompact(signature)).resolves.toBe(true); + describe("custody", () => { + it.each(custodyCompactSignatures)( + "verifies valid message", + async (signature) => { + await expect(verifyCompact(signature)).resolves.toBe(true); + } + ); + }); + + describe("app_key", () => { + it.each(appKeyCompactSignatures)( + "verifies valid message", + async (signature) => { + await expect(verifyCompact(signature)).resolves.toBe(true); + } + ); }); }); describe("verify", () => { - it.each(signatures)("verifies valid message", async (signature) => { - await expect(verify(signature)).resolves.toBe(true); + describe("custody", () => { + it.each(custodySignatures)("verifies valid message", async (signature) => { + await expect(verify(signature)).resolves.toBe(true); + }); + }); + + describe("app_key", () => { + it.each(appKeySignatures)("verifies valid message", async (signature) => { + await expect(verify(signature)).resolves.toBe(true); + }); }); }); describe("sign", () => { - it("signs any payload", async () => { - const signature = await sign({ - fid: 1, - payload: { test: true }, - signer: { - type: "custody", - custodyAddress: "0x1234567890abcdef1234567890abcdef12345678", - }, - signMessage: (message) => { - expect(typeof message === "string").toBe(true); - expect(message.length).toBeGreaterThan(0); + describe("custody", () => { + it("signs any payload", async () => { + const signature = await sign({ + fid: 1, + payload: { test: true }, + signer: { + type: "custody", + custodyAddress: "0x1234567890abcdef1234567890abcdef12345678", + }, + signMessage: (message) => { + expect(typeof message === "string").toBe(true); + expect(message.length).toBeGreaterThan(0); - return Promise.resolve("0x0000000"); - }, - }); + return Promise.resolve("0x0000000"); + }, + }); - expect(signature).toMatchObject({ - compact: expect.any(String), - json: { - header: expect.any(String), - payload: expect.any(String), - signature: expect.any(String), - }, + expect(signature).toMatchObject({ + compact: expect.any(String), + json: { + header: expect.any(String), + payload: expect.any(String), + signature: expect.any(String), + }, + }); + + expect(decodePayload(signature.json.payload)).toEqual({ test: true }); + expect(decodeHeader(signature.json.header)).toEqual({ + fid: 1, + type: "custody", + key: "0x1234567890abcdef1234567890abcdef12345678", + }); + expect(decodeCustodyTypeSignature(signature.json.signature)).toEqual( + "0x0000000" + ); }); + }); - expect(decodePayload(signature.json.payload)).toEqual({ test: true }); - expect(decodeHeader(signature.json.header)).toEqual({ - fid: 1, - type: "custody", - key: "0x1234567890abcdef1234567890abcdef12345678", + describe("app_key", () => { + it("signs any payload", async () => { + const privateKey = ed25519.utils.randomPrivateKey(); + let messageSignature: Hex = "0x"; + const signature = await sign({ + fid: 1, + payload: { test: true }, + signer: { + type: "app_key", + appKey: bytesToHex(ed25519.getPublicKey(privateKey)), + }, + signMessage: (message) => { + expect(typeof message === "string").toBe(true); + expect(message.length).toBeGreaterThan(0); + + messageSignature = bytesToHex( + ed25519.sign(Buffer.from(message, "utf-8"), privateKey) + ); + + return Promise.resolve(messageSignature); + }, + }); + + expect(signature).toMatchObject({ + compact: expect.any(String), + json: { + header: expect.any(String), + payload: expect.any(String), + signature: expect.any(String), + }, + }); + + expect(Buffer.from(signature.json.signature, "base64url")).toHaveProperty( + "byteLength", + 64 + ); + expect(decodePayload(signature.json.payload)).toEqual({ test: true }); + expect(decodeHeader(signature.json.header)).toEqual({ + fid: 1, + type: "app_key", + key: bytesToHex(ed25519.getPublicKey(privateKey)), + }); + expect(decodeAppKeyTypeSignature(signature.json.signature)).toEqual( + messageSignature + ); }); - expect(decodeSignature(signature.json.signature)).toEqual("0x0000000"); }); }); @@ -140,17 +244,77 @@ describe("decodePayload", () => { describe("encodeSignature", () => { it("encodes signature", () => { - const value = encodeSignature("0x0000000"); + const input = "0x0000000"; + const value = encodeSignature(Buffer.from("0x0000000", "utf-8")); expect(typeof value).toBe("string"); + expect(Buffer.from(input, "utf-8").toString("base64url")).toEqual(value); + }); + + it("encodes signature as Buffer", () => { + const input = hexToBytes("0x0000001"); + const value = encodeSignature(Buffer.from(input)); + + expect(Buffer.from(input).toString("base64url")).toEqual(value); + }); +}); + +describe("decodeAppKeyTypeSignature", () => { + it("decodes signature (string)", () => { + const buf = Buffer.from("0x0000000", "utf-8"); + const encodedSignature = encodeSignature(buf); + const value = decodeAppKeyTypeSignature(encodedSignature); + + expect(value).toBe(bytesToHex(buf)); + }); + + it("decodes signature (from buffer)", () => { + const input = hexToBytes("0x0000001"); + const encodedSignature = encodeSignature(Buffer.from(input)); + const value = decodeAppKeyTypeSignature(encodedSignature); + + expect(value).toBe(bytesToHex(input)); + }); +}); + +describe("decodeCustodyTypeSignature", () => { + it("decodes signature (string)", () => { + const buf = Buffer.from("0x0000000", "utf-8"); + const encodedSignature = encodeSignature(buf); + const value = decodeCustodyTypeSignature(encodedSignature); + + expect(value).toBe(buf.toString("utf-8")); + }); + + it("decodes signature (from buffer)", () => { + const input = "0x0000001"; + const encodedSignature = encodeSignature(Buffer.from(input, "utf-8")); + const value = decodeCustodyTypeSignature(encodedSignature); + + expect(value).toBe(input); }); }); -describe("decodeSignature", () => { - it("decodes signature", () => { - const encodedSignature = encodeSignature("0x0000000"); - const value = decodeSignature(encodedSignature); +describe("signMessageWithAppKey", () => { + it("signs any payload", async () => { + const privateKey = ed25519.utils.randomPrivateKey(); + const signature = await sign({ + fid: 1, + payload: { test: true }, + signer: { + type: "app_key", + appKey: bytesToHex(ed25519.getPublicKey(privateKey)), + }, + signMessage: signMessageWithAppKey(privateKey), + }); - expect(value).toBe("0x0000000"); + expect(signature).toMatchObject({ + compact: expect.any(String), + json: { + header: expect.any(String), + payload: expect.any(String), + signature: expect.any(String), + }, + }); }); }); diff --git a/packages/frames.js/src/farcaster-v2/json-signature.ts b/packages/frames.js/src/farcaster-v2/json-signature.ts index d0e88ea4..578fdd1d 100644 --- a/packages/frames.js/src/farcaster-v2/json-signature.ts +++ b/packages/frames.js/src/farcaster-v2/json-signature.ts @@ -1,7 +1,16 @@ -import { createPublicClient, http, parseAbi } from "viem"; +import { + bytesToHex, + createPublicClient, + hexToBytes, + http, + parseAbi, +} from "viem"; import { optimism } from "viem/chains"; import type { JsonObject } from "../core/types"; import { base64urlDecode, base64urlEncode } from "../lib/base64url"; +import { sign as signEd25519, verify as verifyEd25519 } from "./es25519"; +import { verifyAppKeyWithNeynar } from "./verify"; +import type { SignMessageFunction, VerifyAppKeyFunction } from "./types"; export class InvalidJFSHeaderError extends Error {} @@ -9,7 +18,11 @@ export class InvalidJFSPayloadError extends Error {} export class InvalidJFSCompactSignatureError extends Error {} -export class InvalidJFSSignatureError extends Error {} +export class InvalidJFSSignatureError extends Error { + constructor(public cause: unknown) { + super(); + } +} export type JSONFarcasterSignatureHeader = { fid: number; @@ -58,7 +71,7 @@ type GenerateJSONFarcasterSignatureInput = { fid: number; signer: JSONFarcasterSignatureSigner; payload: JsonObject; - signMessage: (message: string) => Promise<`0x${string}`>; + signMessage: SignMessageFunction; }; export type SignResult = { @@ -71,6 +84,7 @@ export type SignResult = { * * @example * ```ts + * // signing domain for frame manifest * const signature = await sign({ * fid: 1, * signer: { @@ -94,14 +108,30 @@ export async function sign( const signature = await input.signMessage( `${encodedHeader}.${encodedPayload}` ); - const encodedSignature = encodeSignature(signature); + let base64urlEncodedSignature; + + /** + * Farcaster seems to encode signatures differently based on signer type. + * + * For app_key it uses signature data as bytes, so encoding raw data to base64url + * For custody it uses signature as hex string, which is then encoded to base64url + */ + if (input.signer.type === "app_key") { + base64urlEncodedSignature = encodeSignature( + Buffer.from(hexToBytes(signature)) + ); + } else { + base64urlEncodedSignature = encodeSignature( + Buffer.from(signature, "utf-8") + ); + } return { - compact: `${encodedHeader}.${encodedPayload}.${encodedSignature}`, + compact: `${encodedHeader}.${encodedPayload}.${base64urlEncodedSignature}`, json: { header: encodedHeader, payload: encodedPayload, - signature: encodedSignature, + signature: base64urlEncodedSignature, }, }; } @@ -115,7 +145,10 @@ export async function sign( * ``` */ export async function verifyCompact( - compactSignature: string + compactSignature: string, + options?: { + verifyAppKey?: VerifyAppKeyFunction; + } ): Promise { const [encodedHeader, encodedPayload, encodedSignature] = compactSignature.split("."); @@ -124,15 +157,18 @@ export async function verifyCompact( throw new InvalidJFSCompactSignatureError(); } - return verify({ - header: encodedHeader, - payload: encodedPayload, - signature: encodedSignature, - }); + return verify( + { + header: encodedHeader, + payload: encodedPayload, + signature: encodedSignature, + }, + options + ); } /** - * Verifies JSON Farcaster Signature + * Verifies JSON Farcaster Signature either signed using custody address or app key * * @example * ```ts @@ -142,17 +178,54 @@ export async function verifyCompact( * signature: "encoded signature", * }); * ``` + * + * @example + * ```ts + * // use custom hub url + * const isValid = await verify({ + * header: "encoded header", + * payload: "encoded payload", + * signature: "encoded signature", + * }, { + * verifyAppKey: verifyAppKeyWithNeynar({ apiKey: 'api key', hubUrl: "https://hub-api.neynar.com" }), + * }); + * ``` */ export async function verify( - signatureObject: JSONFarcasterSignatureEncoded + signatureObject: JSONFarcasterSignatureEncoded, + options: { + verifyAppKey?: VerifyAppKeyFunction; + } = {} ): Promise { const decodedHeader = decodeHeader(signatureObject.header); - if (decodedHeader.type !== "custody") { - throw new InvalidJFSHeaderError("Only custody signatures are supported"); + if (decodedHeader.type === "app_key") { + const signature = base64urlDecode(signatureObject.signature); + const signedInput = Buffer.from( + `${signatureObject.header}.${signatureObject.payload}` + ); + const appKey = hexToBytes(decodedHeader.key); + const isValid = verifyEd25519(signature, signedInput, appKey); + + if (!isValid) { + return false; + } + + const verifyAppKey = options.verifyAppKey ?? verifyAppKeyWithNeynar(); + + const appKeyResult = await verifyAppKey( + decodedHeader.fid, + decodedHeader.key + ); + + if (!appKeyResult.valid) { + return false; + } + + return true; } - const signature = decodeSignature(signatureObject.signature); + const signature = decodeCustodyTypeSignature(signatureObject.signature); const publicClient = createPublicClient({ chain: optimism, transport: http(), @@ -187,7 +260,10 @@ type JSONFarcasterSignatureSigner = } | { type: "app_key"; - appKey: string; + /** + * Farcaster signer public key + */ + appKey: `0x${string}`; }; export function encodeHeader( @@ -195,11 +271,14 @@ export function encodeHeader( signer: JSONFarcasterSignatureSigner ): string { return base64urlEncode( - JSON.stringify({ - fid, - type: signer.type, - key: signer.type === "custody" ? signer.custodyAddress : signer.appKey, - }) + Buffer.from( + JSON.stringify({ + fid, + type: signer.type, + key: signer.type === "custody" ? signer.custodyAddress : signer.appKey, + }), + "utf-8" + ) ); } @@ -208,7 +287,7 @@ export function decodeHeader( ): JSONFarcasterSignatureHeader { try { const decodedHeader = base64urlDecode(encodedHeader); - const value: unknown = JSON.parse(decodedHeader); + const value: unknown = JSON.parse(decodedHeader.toString("utf-8")); const header: JSONFarcasterSignatureHeader = { fid: 0, type: "custody", @@ -261,13 +340,13 @@ export function decodeHeader( } export function encodePayload(data: JsonObject): string { - return base64urlEncode(JSON.stringify(data)); + return base64urlEncode(Buffer.from(JSON.stringify(data), "utf-8")); } export function decodePayload(encodedPayload: string): JsonObject { try { const decodedPayload = base64urlDecode(encodedPayload); - const value: unknown = JSON.parse(decodedPayload); + const value: unknown = JSON.parse(decodedPayload.toString("utf-8")); if (typeof value !== "object") { throw new InvalidJFSPayloadError(); @@ -287,24 +366,62 @@ export function decodePayload(encodedPayload: string): JsonObject { } } -export function encodeSignature(signature: `0x${string}`): string { +export function encodeSignature(signature: Buffer): string { return base64urlEncode(signature); } -export function decodeSignature(signature: string): `0x${string}` { +export function decodeAppKeyTypeSignature(signature: string): `0x${string}` { try { - const signatureHash = base64urlDecode(signature); + return bytesToHex(base64urlDecode(signature)); + } catch (e) { + throw new InvalidJFSSignatureError(e); + } +} - if (!signatureHash.startsWith("0x")) { - throw new InvalidJFSSignatureError(); - } +export function decodeCustodyTypeSignature(signature: string): `0x${string}` { + try { + const decoded = base64urlDecode(signature).toString("utf-8"); - return signatureHash as `0x${string}`; - } catch (e) { - if (e instanceof InvalidJFSSignatureError) { - throw e; + if (!decoded.startsWith("0x")) { + throw new Error("Invalid signature, must contain hex text"); } - throw new InvalidJFSSignatureError(); + return decoded as `0x${string}`; + } catch (e) { + throw new InvalidJFSSignatureError(e); } } + +/** + * Signs message using app key + * + * @example + * ```ts + * await sign({ + * fid: 1, + * signer: { + * type: "app_key", + * appKey: "0x000000000000000000000000" // signer public key + * }, + * payload: { + * any: 10, + * }, + * signMessage: signMessageWithAppKey( + * // signer private key + * "0x000000000000000000000000000000" + * ), + * }) + * ``` + */ +export function signMessageWithAppKey( + privateKey: `0x${string}` | Uint8Array +): SignMessageFunction { + return (message) => { + const signature = signEd25519( + Buffer.from(message, "utf-8"), + typeof privateKey === "string" ? hexToBytes(privateKey) : privateKey + ); + + return Promise.resolve(bytesToHex(signature)); + }; +} diff --git a/packages/frames.js/src/farcaster-v2/types.ts b/packages/frames.js/src/farcaster-v2/types.ts index 8a0962ff..574e8157 100644 --- a/packages/frames.js/src/farcaster-v2/types.ts +++ b/packages/frames.js/src/farcaster-v2/types.ts @@ -10,3 +10,19 @@ export type FarcasterManifest = z.infer; export type Frame = FrameEmbedNext; export type PartialFarcasterManifest = PartialDeep; + +export type VerifyAppKeyFunctionResult = + | { + readonly valid: false; + } + | { + readonly valid: true; + readonly appFid: number; + }; + +export type VerifyAppKeyFunction = ( + fid: number, + appKey: string +) => Promise; + +export type SignMessageFunction = (message: string) => Promise<`0x${string}`>; diff --git a/packages/frames.js/src/farcaster-v2/verify.ts b/packages/frames.js/src/farcaster-v2/verify.ts new file mode 100644 index 00000000..2b5c3210 --- /dev/null +++ b/packages/frames.js/src/farcaster-v2/verify.ts @@ -0,0 +1,131 @@ +import { AbiParameters } from "ox"; +import { z } from "zod"; +import type { VerifyAppKeyFunction } from "./types"; + +class VerifyAppKeyWithNeynarError extends Error { + constructor( + message: string, + public cause?: unknown + ) { + super(message); + } +} + +type VerifyAppKeyWithNeynarOptions = { + /** + * @defaultValue process.env.NEYNAR_API_KEY + */ + apiKey?: string; + /** + * @defaultValue "https://hub-api.neynar.com" + */ + hubUrl?: string; +}; + +export function verifyAppKeyWithNeynar({ + apiKey = process.env.NEYNAR_API_KEY, + hubUrl = "https://hub-api.neynar.com", +}: VerifyAppKeyWithNeynarOptions = {}): VerifyAppKeyFunction { + if (!apiKey) { + throw new Error( + "verifyAppKeyWithNeynar requires appKey to be passed in or set as an environment variable NEYNAR_API_KEY" + ); + } + + const verifier = createVerifyAppKeyWithHub(hubUrl, { + headers: { + "x-api-key": apiKey, + }, + cache: "no-cache", + }); + + return verifier; +} + +export const signedKeyRequestAbi = [ + { + components: [ + { + name: "requestFid", + type: "uint256", + }, + { + name: "requestSigner", + type: "address", + }, + { + name: "signature", + type: "bytes", + }, + { + name: "deadline", + type: "uint256", + }, + ], + name: "SignedKeyRequest", + type: "tuple", + }, +] as const; + +const hubResponseSchema = z.object({ + events: z.array( + z.object({ + signerEventBody: z.object({ + key: z.string(), + metadata: z.string(), + }), + }) + ), +}); + +function createVerifyAppKeyWithHub( + hubUrl: string, + requestOptions: RequestInit +): VerifyAppKeyFunction { + return async (fid, appKey) => { + const url = new URL("/v1/onChainSignersByFid", hubUrl); + url.searchParams.append("fid", fid.toString()); + + const response = await fetch(url, requestOptions); + + if (response.status !== 200) { + throw new VerifyAppKeyWithNeynarError( + "Error fetching from Hub API, non-200 status code received", + await response.text() + ); + } + + const parsedResponse = hubResponseSchema.safeParse(await response.json()); + + if (parsedResponse.error) { + throw new VerifyAppKeyWithNeynarError( + "Error parsing Hub response", + parsedResponse.error + ); + } + + const appKeyLower = appKey.toLowerCase(); + + const signerEvent = parsedResponse.data.events.find( + (event) => event.signerEventBody.key.toLowerCase() === appKeyLower + ); + + if (!signerEvent) { + return { valid: false }; + } + + const decoded = AbiParameters.decode( + signedKeyRequestAbi, + Buffer.from(signerEvent.signerEventBody.metadata, "base64") + ); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- on type level this returns a tuple + if (decoded.length !== 1) { + throw new VerifyAppKeyWithNeynarError("Error decoding metadata"); + } + + const appFid = Number(decoded[0].requestFid); + + return { valid: true, appFid }; + }; +} diff --git a/packages/frames.js/src/lib/base64url.test.ts b/packages/frames.js/src/lib/base64url.test.ts index 030fb971..5770d2e0 100644 --- a/packages/frames.js/src/lib/base64url.test.ts +++ b/packages/frames.js/src/lib/base64url.test.ts @@ -2,20 +2,16 @@ import { base64urlDecode, base64urlEncode } from "./base64url"; describe("base64urlEncode", () => { it('works the same as native buffer toString("base64url")', () => { - const data = "hello world"; + const data = Buffer.from("hello world"); - expect(base64urlEncode(data)).toEqual( - Buffer.from(data, "utf-8").toString("base64url") - ); + expect(base64urlEncode(data)).toEqual(data.toString("base64url")); }); }); describe("base64urlDecode", () => { it('decodes the same as native buffer from("base64url")', () => { - const data = base64urlEncode("hello world"); + const data = base64urlEncode(Buffer.from("hello world")); - expect(base64urlDecode(data)).toEqual( - Buffer.from(data, "base64url").toString("utf-8") - ); + expect(base64urlDecode(data)).toEqual(Buffer.from("hello world")); }); }); diff --git a/packages/frames.js/src/lib/base64url.ts b/packages/frames.js/src/lib/base64url.ts index 9ad30888..582e2b39 100644 --- a/packages/frames.js/src/lib/base64url.ts +++ b/packages/frames.js/src/lib/base64url.ts @@ -1,13 +1,13 @@ -export function base64urlEncode(data: string): string { +export function base64urlEncode(data: Buffer): string { // we could use .toString('base64url') on buffer, but that throws in browser - return Buffer.from(data, "utf-8") + return data .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } -export function base64urlDecode(encodedData: string): string { +export function base64urlDecode(encodedData: string): Buffer { const encodedChunks = encodedData.length % 4; const base64 = encodedData .replace(/-/g, "+") @@ -15,5 +15,5 @@ export function base64urlDecode(encodedData: string): string { .padEnd(encodedData.length + Math.max(0, 4 - encodedChunks), "="); // we could use base64url on buffer, but that throws in browser - return Buffer.from(base64, "base64").toString("utf-8"); + return Buffer.from(base64, "base64"); } diff --git a/yarn.lock b/yarn.lock index 4829ea58..0ca28cef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2452,16 +2452,6 @@ "@farcaster/frame-core" "0.0.24" ox "^0.4.4" -"@farcaster/frame-node@^0.0.13": - version "0.0.13" - resolved "https://registry.yarnpkg.com/@farcaster/frame-node/-/frame-node-0.0.13.tgz#ba9d37358589b263aa9566cf1763b1eb528672e6" - integrity sha512-99rhLSpyhKKnWFh8eWx8N5tTYEToFkIzg9r5lRm2bvfsZaT0zXr/cA+G9pWDFZyodfoyS/cLHS46Qsm8P52uTg== - dependencies: - "@farcaster/frame-core" "0.0.24" - "@noble/curves" "^1.7.0" - ox "^0.4.4" - zod "^3.24.1" - "@farcaster/frame-sdk@^0.0.26": version "0.0.26" resolved "https://registry.yarnpkg.com/@farcaster/frame-sdk/-/frame-sdk-0.0.26.tgz#2cf5c5e9e8ecdbdbc244e55f41129fc1caa9b88c" @@ -3576,13 +3566,6 @@ dependencies: "@noble/hashes" "1.6.0" -"@noble/curves@^1.7.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.8.0.tgz#fe035a23959e6aeadf695851b51a87465b5ba8f7" - integrity sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ== - dependencies: - "@noble/hashes" "1.7.0" - "@noble/curves@~1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" @@ -3595,6 +3578,11 @@ resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-2.0.0.tgz#5964c8190a4b4b804985717ca566113b93379e43" integrity sha512-/extjhkwFupyopDrt80OMWKdLgP429qLZj+z6sYJz90rF2Iz0gjZh2ArMKPImUl13Kx+0EXI2hN9T/KJV0/Zng== +"@noble/ed25519@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-2.2.3.tgz#e189810490302b076e17895b667a06cbe54339f4" + integrity sha512-iHV8eI2mRcUmOx159QNrU8vTpQ/Xm70yJ2cTk3Trc86++02usfqFoNl6x0p3JN81ZDS/1gx6xiK0OwrgqCT43g== + "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" @@ -3620,10 +3608,10 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.6.1.tgz#df6e5943edcea504bac61395926d6fd67869a0d5" integrity sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w== -"@noble/hashes@1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.0.tgz#5d9e33af2c7d04fee35de1519b80c958b2e35e39" - integrity sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w== +"@noble/hashes@^1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.1.tgz#5738f6d765710921e7a751e00c20ae091ed8db0f" + integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== "@noble/secp256k1@1.7.1": version "1.7.1"