From 104b94037fa9e53bf895ae32cb55c53c68764ec9 Mon Sep 17 00:00:00 2001 From: Raymond Z Date: Wed, 22 Jan 2025 03:04:24 -0500 Subject: [PATCH] packages/chain-ethereum: add sign in with farcaster (#438) * add siwfsigner, verify fc message to farcasterSignerAddress * use requestId to encode canvas pubkey, update siwf message encoding, add integration test for siwf verification * examples/chat: prototype siwf login to chat example * add static methods for generating requestId, update fixtures, tests pass * fix parsing of delegate did, return custody address * workaround for creating messages * examples/chat: default back to burner wallet * examples/chat: handle signer switching * factor out newSIWFSession * add eip712signer to chat * sync fixes: add eip712signer and siwfsigner to cli, add siwfsigner to chat init --- examples/chat/package.json | 1 + examples/chat/src/App.tsx | 67 ++-- examples/chat/src/connect/ConnectSIWF.tsx | 106 ++++++ examples/chat/src/connect/index.tsx | 6 +- package-lock.json | 343 +++++++++++++++++- packages/chain-ethereum/src/index.ts | 1 + .../chain-ethereum/src/siwf/SIWFSigner.ts | 202 +++++++++++ packages/chain-ethereum/src/siwf/index.ts | 3 + packages/chain-ethereum/src/siwf/types.ts | 27 ++ packages/chain-ethereum/src/siwf/utils.ts | 59 +++ .../chain-ethereum/test/SIWFSigner.test.ts | 112 ++++++ packages/cli/src/commands/run.ts | 12 +- packages/interfaces/src/SessionSigner.ts | 18 +- .../signatures/src/AbstractSessionSigner.ts | 52 ++- 14 files changed, 969 insertions(+), 40 deletions(-) create mode 100644 examples/chat/src/connect/ConnectSIWF.tsx create mode 100644 packages/chain-ethereum/src/siwf/SIWFSigner.ts create mode 100644 packages/chain-ethereum/src/siwf/index.ts create mode 100644 packages/chain-ethereum/src/siwf/types.ts create mode 100644 packages/chain-ethereum/src/siwf/utils.ts create mode 100644 packages/chain-ethereum/test/SIWFSigner.test.ts diff --git a/examples/chat/package.json b/examples/chat/package.json index 21157dd11..a4b177f3d 100644 --- a/examples/chat/package.json +++ b/examples/chat/package.json @@ -21,6 +21,7 @@ "@canvas-js/interfaces": "0.14.0-next.0", "@canvas-js/modeldb-sqlite-wasm": "0.14.0-next.0", "@cosmjs/encoding": "^0.32.3", + "@farcaster/auth-kit": "^0.6.0", "@keplr-wallet/types": "^0.11.64", "@libp2p/interface": "^2.2.1", "@magic-ext/auth": "^4.3.2", diff --git a/examples/chat/src/App.tsx b/examples/chat/src/App.tsx index c739f1001..b1b47d803 100644 --- a/examples/chat/src/App.tsx +++ b/examples/chat/src/App.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState } from "react" import type { SessionSigner } from "@canvas-js/interfaces" -import { SIWESigner } from "@canvas-js/chain-ethereum" +import { SIWESigner, SIWFSigner, Eip712Signer } from "@canvas-js/chain-ethereum" import { ATPSigner } from "@canvas-js/chain-atp" import { CosmosSigner } from "@canvas-js/chain-cosmos" import { SubstrateSigner } from "@canvas-js/chain-substrate" @@ -11,6 +11,9 @@ import type { Contract } from "@canvas-js/core" import { useCanvas } from "@canvas-js/hooks" +import { AuthKitProvider } from "@farcaster/auth-kit" +import { JsonRpcProvider } from "ethers" + import { AppContext } from "./AppContext.js" import { Messages } from "./Chat.js" import { MessageComposer } from "./MessageComposer.js" @@ -21,11 +24,21 @@ import { Connect } from "./connect/index.js" import { LogStatus } from "./LogStatus.js" import { contract } from "./contract.js" -const topic = "chat-example.canvas.xyz" +export const topic = "chat-example.canvas.xyz" const wsURL = import.meta.env.VITE_CANVAS_WS_URL ?? null console.log("websocket API URL:", wsURL) +const config = { + // For a production app, replace this with an Optimism Mainnet + // RPC URL from a provider like Alchemy or Infura. + relay: "https://relay.farcaster.xyz", + rpcUrl: "https://mainnet.optimism.io", + domain: "chat-example.canvas.xyz", + siweUri: "https://chat-example.canvas.xyz", + provider: new JsonRpcProvider(undefined, 10), +} + export const App: React.FC<{}> = ({}) => { const [sessionSigner, setSessionSigner] = useState(null) const [address, setAddress] = useState(null) @@ -35,32 +48,42 @@ export const App: React.FC<{}> = ({}) => { const { app } = useCanvas(wsURL, { topic: topicRef.current, contract: contract, - signers: [new SIWESigner(), new ATPSigner(), new CosmosSigner(), new SubstrateSigner({}), new SolanaSigner()], + signers: [ + new SIWESigner(), + new Eip712Signer(), + new SIWFSigner(), + new ATPSigner(), + new CosmosSigner(), + new SubstrateSigner({}), + new SolanaSigner(), + ], }) return ( - {app ? ( -
-
-
-
- + + {app ? ( +
+
+
+
+ +
+ +
+
+ + + + +
- -
-
- - - - -
-
-
- ) : ( -
Connecting to {wsURL}...
- )} + + ) : ( +
Connecting to {wsURL}...
+ )} +
) } diff --git a/examples/chat/src/connect/ConnectSIWF.tsx b/examples/chat/src/connect/ConnectSIWF.tsx new file mode 100644 index 000000000..b4624202a --- /dev/null +++ b/examples/chat/src/connect/ConnectSIWF.tsx @@ -0,0 +1,106 @@ +import "@farcaster/auth-kit/styles.css" + +import React, { useContext, useEffect, useRef, useState } from "react" +import { hexlify, getBytes } from "ethers" +import { SIWFSigner } from "@canvas-js/chain-ethereum" +import { ed25519 } from "@canvas-js/signatures" +import { SignInButton, useProfile } from "@farcaster/auth-kit" + +import { topic } from "../App.js" +import { AppContext } from "../AppContext.js" + +export interface ConnectSIWFProps {} + +export const ConnectSIWF: React.FC = ({}) => { + const { app, setSessionSigner, setAddress } = useContext(AppContext) + + const profile = useProfile() + const { + isAuthenticated, + profile: { fid, displayName, custody, verifications }, + } = profile + + const [error, setError] = useState(null) + const [requestId, setRequestId] = useState(null) + const [privateKey, setPrivateKey] = useState(null) + + const initialRef = useRef(false) + useEffect(() => { + if (initialRef.current) { + return + } + + initialRef.current = true + + const { requestId, privateKey } = SIWFSigner.newSIWFRequestId(topic) + setRequestId(requestId) + setPrivateKey(hexlify(privateKey)) + }, []) + + if (error !== null) { + return ( +
+ {error.message} +
+ ) + } else if (!privateKey || !requestId || !app) { + return ( +
+ +
+ ) + } else { + return ( +
+ {isAuthenticated && ( +
+

+ Hello, {displayName}! Your FID is {fid}. +

+

Your custody address is:

+
{custody}
+

Your connected signers:

+ {verifications?.map((v, i) =>
{v}
)} +
+ )} + { + const { signature, message } = result + if (!message || !signature) { + setError(new Error("login succeeded but did not return a valid SIWF message")) + return + } + + const { authorizationData, topic, custodyAddress } = SIWFSigner.parseSIWFMessage(message, signature) + const signer = new SIWFSigner({ custodyAddress, privateKey: privateKey.slice(2) }) + const address = await signer.getDid() + + const timestamp = new Date(authorizationData.siweIssuedAt).valueOf() + const { payload, signer: delegateSigner } = await signer.newSIWFSession( + topic, + authorizationData, + timestamp, + getBytes(privateKey), + ) + setAddress(address) + setSessionSigner(signer) + app.updateSigners([ + signer, + ...app.signers.getAll().filter((signer) => signer.key !== "chain-ethereum-farcaster"), + ]) + app.messageLog.append(payload, { signer: delegateSigner }) + console.log("started SIWF chat session", authorizationData) + }} + onError={(...args) => { + console.log("received SIWF error", args) + }} + onSignOut={(...args) => { + setAddress(null) + setSessionSigner(null) + }} + /> +
+ ) + } +} diff --git a/examples/chat/src/connect/index.tsx b/examples/chat/src/connect/index.tsx index 147f2ca0a..e87d45d08 100644 --- a/examples/chat/src/connect/index.tsx +++ b/examples/chat/src/connect/index.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react" import { ConnectSIWEBurner } from "./ConnectSIWEBurner.js" import { ConnectSIWE } from "./ConnectSIWE.js" +import { ConnectSIWF } from "./ConnectSIWF.js" import { ConnectSIWEViem } from "./ConnectSIWEViem.js" import { ConnectEIP712Burner } from "./ConnectEIP712Burner.js" import { ConnectEIP712 } from "./ConnectEIP712.js" @@ -27,6 +28,7 @@ export const Connect: React.FC<{}> = ({}) => { > + @@ -37,7 +39,7 @@ export const Connect: React.FC<{}> = ({}) => { {/* */} - + @@ -48,6 +50,8 @@ export const Connect: React.FC<{}> = ({}) => { const Method: React.FC<{ method: string }> = (props) => { switch (props.method) { + case "farcaster": + return case "burner": return case "burner-eip712": diff --git a/package-lock.json b/package-lock.json index 3cd9441d9..cc985556d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "@canvas-js/interfaces": "0.14.0-next.0", "@canvas-js/modeldb-sqlite-wasm": "0.14.0-next.0", "@cosmjs/encoding": "^0.32.3", + "@farcaster/auth-kit": "^0.6.0", "@keplr-wallet/types": "^0.11.64", "@libp2p/interface": "^2.2.1", "@magic-ext/auth": "^4.3.2", @@ -3132,6 +3133,11 @@ } } }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, "node_modules/@esbuild-plugins/node-globals-polyfill": { "version": "0.2.3", "license": "ISC", @@ -5317,6 +5323,35 @@ "node": ">=8" } }, + "node_modules/@farcaster/auth-client": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@farcaster/auth-client/-/auth-client-0.3.0.tgz", + "integrity": "sha512-tOQ5r40V7sYKOhk7QEzVnU9wAM0FGpQ2Y3jXpNfNPw61lFpYc4kRAxTJOOc2NF1msYdHGFVDvG8xe9Qf36M1RA==", + "dependencies": { + "neverthrow": "^6.1.0", + "siwe": "^2.1.4" + }, + "peerDependencies": { + "ethers": "5.x || 6.x", + "viem": "1.x || 2.x" + } + }, + "node_modules/@farcaster/auth-kit": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@farcaster/auth-kit/-/auth-kit-0.6.0.tgz", + "integrity": "sha512-PkQUSZnC+JLAW+6nGAMnjU5mcvVnCu1f5Pmwg1tBXShCqqcZpZfurwmcyANfkgzY3ZbaBE5YVSwKpoT6Cl1sZA==", + "dependencies": { + "@farcaster/auth-client": "^0.3.0", + "@vanilla-extract/css": "^1.14.0", + "ethers": "^6.12.0", + "qrcode": "^1.5.3", + "react-remove-scroll": "^2.5.7" + }, + "peerDependencies": { + "react": ">= 17", + "react-dom": ">= 17" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.0", "license": "MIT", @@ -12247,6 +12282,35 @@ "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, + "node_modules/@vanilla-extract/css": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.17.0.tgz", + "integrity": "sha512-W6FqVFDD+C71ZlKsuj0MxOXSvHb1tvQ9h/+79aYfi097wLsALrnnBzd0by8C///iurrpQ3S+SH74lXd7Lr9MvA==", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.6", + "css-what": "^6.1.0", + "cssesc": "^3.0.0", + "csstype": "^3.0.7", + "dedent": "^1.5.3", + "deep-object-diff": "^1.1.9", + "deepmerge": "^4.2.2", + "lru-cache": "^10.4.3", + "media-query-parser": "^2.0.2", + "modern-ahocorasick": "^1.0.0", + "picocolors": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/css/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.6.tgz", + "integrity": "sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==" + }, "node_modules/@vercel/nft": { "version": "0.27.6", "dev": true, @@ -15899,7 +15963,6 @@ }, "node_modules/css-what": { "version": "6.1.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -15910,7 +15973,6 @@ }, "node_modules/cssesc": { "version": "3.0.0", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -16419,6 +16481,14 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.4.3", "license": "MIT" @@ -16443,6 +16513,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-diff": { "version": "1.0.1", "license": "MIT" @@ -16500,6 +16583,19 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-gateway": { "version": "4.2.0", "license": "BSD-2-Clause", @@ -16917,6 +17013,11 @@ "heap": ">= 0.2.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/dir-glob": { "version": "3.0.1", "license": "MIT", @@ -23752,6 +23853,14 @@ "dev": true, "license": "MIT" }, + "node_modules/media-query-parser": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", + "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==", + "dependencies": { + "@babel/runtime": "^7.12.5" + } + }, "node_modules/media-typer": { "version": "0.3.0", "license": "MIT", @@ -24936,6 +25045,11 @@ "node": ">= 8" } }, + "node_modules/modern-ahocorasick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", + "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==" + }, "node_modules/mortice": { "version": "3.0.6", "license": "Apache-2.0 OR MIT", @@ -25117,6 +25231,11 @@ "node": ">= 0.4.0" } }, + "node_modules/neverthrow": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-6.2.2.tgz", + "integrity": "sha512-POR1FACqdK9jH0S2kRPzaZEvzT11wsOxLW520PQV/+vKi9dQe+hXq19EiOvYx7lSRaF5VB9lYGsPInynrnN05w==" + }, "node_modules/next": { "version": "14.2.21", "license": "MIT", @@ -26089,7 +26208,6 @@ "node_modules/p-try": { "version": "2.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -27358,6 +27476,22 @@ "node": ">=6.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "peer": true, @@ -27365,6 +27499,198 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/qrcode/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.13.0", "license": "BSD-3-Clause", @@ -28396,6 +28722,11 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "node_modules/requireg": { "version": "0.2.2", "peer": true, @@ -29162,7 +29493,6 @@ }, "node_modules/set-blocking": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -33694,6 +34024,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/which-typed-array": { "version": "1.1.15", "license": "MIT", diff --git a/packages/chain-ethereum/src/index.ts b/packages/chain-ethereum/src/index.ts index 6cfc07db5..669d2e4b2 100644 --- a/packages/chain-ethereum/src/index.ts +++ b/packages/chain-ethereum/src/index.ts @@ -1,2 +1,3 @@ export * from "./siwe/index.js" +export * from "./siwf/index.js" export * from "./eip712/index.js" diff --git a/packages/chain-ethereum/src/siwf/SIWFSigner.ts b/packages/chain-ethereum/src/siwf/SIWFSigner.ts new file mode 100644 index 000000000..09e84e8ea --- /dev/null +++ b/packages/chain-ethereum/src/siwf/SIWFSigner.ts @@ -0,0 +1,202 @@ +import { verifyMessage, hexlify, getBytes } from "ethers" +import * as siwe from "siwe" +import * as json from "@ipld/dag-json" + +import type { Action, Session, Snapshot, AbstractSessionData, DidIdentifier, Signer } from "@canvas-js/interfaces" +import { AbstractSessionSigner, ed25519 } from "@canvas-js/signatures" +import { assert, DAYS } from "@canvas-js/utils" + +import type { SIWFSessionData, SIWFMessage } from "./types.js" +import { validateSIWFSessionData as validateSIWFSessionDataType, parseAddress, addressPattern } from "./utils.js" + +export interface SIWFSignerInit { + sessionDuration?: number + custodyAddress?: string // optional, but required for a signer to create messages + privateKey?: string // optional, but required for a signer to create messages +} + +/** + * Session signer supporting Sign in with Farcaster. + * + * Instead of calling newSession to start a session, use `SIWFSigner.newSIWFRequestId(topic)` + * to get a requestId, pass it to Farcaster AuthKit, and call newSession() with it. + */ +export class SIWFSigner extends AbstractSessionSigner { + public readonly match = (address: string) => addressPattern.test(address) + + public readonly key: string + public readonly chainId: number + public readonly custodyAddress: string | undefined + + public constructor({ sessionDuration, privateKey, ...init }: SIWFSignerInit = {}) { + super("chain-ethereum-farcaster", ed25519, { sessionDuration: sessionDuration ?? 14 * DAYS }) + + this.chainId = 10 + this.key = `SIWFSigner` + this.custodyAddress = init.custodyAddress + + if (privateKey) { + if (privateKey.length !== 64 || !privateKey.match(/^[0-9a-f]+$/i)) { + throw new Error("SIWFSigner privateKey must be 32 bytes (64 hex chars)") + } + this.target.set(`canvas:signers/chain-ethereum-farcaster/seed`, privateKey) + } + } + + public async getDid(): Promise { + if (!this.custodyAddress) throw new Error("not initialized") + return `did:pkh:farcaster:${this.custodyAddress}` + } + + public getDidParts(): number { + return 4 + } + + public getAddressFromDid(did: DidIdentifier) { + const { address } = parseAddress(did) + return address + } + + public async authorize(sessionData: AbstractSessionData): Promise> { + throw new Error("use siwfSigner.newSIWFSession() instead") + } + + public async newSIWFSession( + topic: string, + authorizationData: SIWFSessionData, + timestamp: number, + privateKey: Uint8Array, + ): Promise<{ payload: Session; signer: Signer | Snapshot> }> { + const signer = this.scheme.create({ type: ed25519.type, privateKey }) + const did = await this.getDid() + + const sessionData = { + topic, + did, + publicKey: signer.publicKey, + context: { + timestamp: timestamp, + duration: this.sessionDuration, + }, + } + const session = authorizationData + ? await this.getSessionFromAuthorizationData(sessionData, authorizationData) + : await this.authorize(sessionData) + + const key = `canvas/${topic}/${did}` + this.target.set(key, json.stringify({ session, ...signer.export() })) + + return { payload: session, signer } + } + + public static newSIWFRequestId(topic: string): { requestId: string; privateKey: Uint8Array } { + const signer = ed25519.create() + const canvasDelegateSignerAddress = signer.publicKey + return { + requestId: `authorize:${topic}:${canvasDelegateSignerAddress}`, + ...signer.export(), + } + } + + public static getSIWFRequestId(topic: string, privateKey: string): string { + const signer = ed25519.create({ type: ed25519.type, privateKey: getBytes(privateKey) }) + const canvasDelegateSignerAddress = signer.publicKey + return `authorize:${topic}:${canvasDelegateSignerAddress}` + } + + /** + * Parse an AuthorizationData, topic, and Farcaster custody address, from a SIWF message. + */ + public static parseSIWFMessage( + siwfMessage: string, + siwfSignature: string, + ): { authorizationData: SIWFSessionData; custodyAddress: string; topic: string } { + const siweMessage = new siwe.SiweMessage(siwfMessage) + + // parse fid, requestId + assert(siweMessage.resources && siweMessage.resources.length > 0, "could not get fid from farcaster login message") + const fidResource = siweMessage.resources[0] + const fid = fidResource.split("/").pop() + assert(fid !== undefined && !isNaN(parseInt(fid, 10)), "invalid fid from farcaster login message") + + assert( + siweMessage.requestId !== undefined, + "farcaster login must include a valid requestId generated by SIWFSigner", + ) + const [prefix, topic] = siweMessage.requestId.split(":", 2) + const canvasDelegateAddress = siweMessage.requestId.slice( + siweMessage.requestId.indexOf(":", siweMessage.requestId.indexOf(":") + 1) + 1, + ) + assert(prefix === "authorize", "invalid requestId from farcaster login message") + assert(siweMessage.issuedAt !== undefined, "invalid issuedAt from farcaster login message") + + const authorizationData: SIWFSessionData = { + custodyAddress: siweMessage.address, + fid, + signature: getBytes(siwfSignature), + siweUri: siweMessage.uri, + siweDomain: siweMessage.domain, + siweNonce: siweMessage.nonce, + siweVersion: siweMessage.version, + siweChainId: siweMessage.chainId, + siweIssuedAt: siweMessage.issuedAt, + siweExpirationTime: siweMessage.expirationTime ?? null, + siweNotBefore: siweMessage.notBefore ?? null, + } + + return { + authorizationData, + topic, + custodyAddress: siweMessage.address, + } + } + + public verifySession(topic: string, session: Session) { + const { + publicKey, + did, + authorizationData, + context: { timestamp }, + } = session + + assert(validateSIWFSessionDataType(authorizationData), "invalid session") + const { address: canvasDelegateAddress } = parseAddress(did) + + // Validate SIWF timestamps, which depend on `timestamp` and wallet-specific checks + // that ensure expirationTime and notBefore are around issuedAt. + const SIXTY_MINUTES = 60 * 60 * 1000 + const issuedAtTimestamp = new Date(authorizationData.siweIssuedAt).valueOf() + assert(issuedAtTimestamp === new Date(timestamp).valueOf(), "issuedAt should match timestamp") + + if (authorizationData.siweExpirationTime) { + const expirationTime = new Date(authorizationData.siweExpirationTime) + assert(expirationTime >= new Date(timestamp), "expirationTime cannot be before timestamp") + assert(expirationTime < new Date(timestamp + SIXTY_MINUTES), "expirationTime is too far after timestamp") + } + if (authorizationData.siweNotBefore) { + const notBefore = new Date(authorizationData.siweNotBefore) + assert(notBefore <= new Date(timestamp), "notBefore cannot be after timestamp") + assert(notBefore > new Date(timestamp - SIXTY_MINUTES), "notBefore too far before timestamp") + } + + const requestId = `authorize:${topic}:${publicKey}` + + const message = new siwe.SiweMessage({ + domain: authorizationData.siweDomain, + address: authorizationData.custodyAddress, + uri: authorizationData.siweUri, + statement: "Farcaster Auth", + version: "1", + chainId: 10, + nonce: authorizationData.siweNonce, + issuedAt: authorizationData.siweIssuedAt, + expirationTime: authorizationData.siweExpirationTime ?? undefined, + notBefore: authorizationData.siweNotBefore ?? undefined, + requestId: requestId, + resources: [`farcaster://fid/${authorizationData.fid}`], + }).prepareMessage() + + const recoveredAddress = verifyMessage(message, hexlify(authorizationData.signature)) + assert(recoveredAddress === authorizationData.custodyAddress, "invalid SIWF signature") + } +} diff --git a/packages/chain-ethereum/src/siwf/index.ts b/packages/chain-ethereum/src/siwf/index.ts new file mode 100644 index 000000000..bbacb4a7f --- /dev/null +++ b/packages/chain-ethereum/src/siwf/index.ts @@ -0,0 +1,3 @@ +export * from "./SIWFSigner.js" +export { SIWFSessionData } from "./types.js" +export { validateSIWFSessionData } from "./utils.js" diff --git a/packages/chain-ethereum/src/siwf/types.ts b/packages/chain-ethereum/src/siwf/types.ts new file mode 100644 index 000000000..9d3170826 --- /dev/null +++ b/packages/chain-ethereum/src/siwf/types.ts @@ -0,0 +1,27 @@ +export type SIWFSessionData = { + custodyAddress: string + fid: string + signature: Uint8Array + siweUri: string + siweDomain: string + siweNonce: string + siweVersion: string + siweChainId: number + siweIssuedAt: string + siweExpirationTime: string | null + siweNotBefore: string | null +} + +export type SIWFMessage = { + version: string + address: string + chainId: number + domain: string + uri: string + nonce: string + issuedAt: string + expirationTime: string | undefined + notBefore: string | undefined + requestId: string + resources: string[] +} diff --git a/packages/chain-ethereum/src/siwf/utils.ts b/packages/chain-ethereum/src/siwf/utils.ts new file mode 100644 index 000000000..a21ea6f24 --- /dev/null +++ b/packages/chain-ethereum/src/siwf/utils.ts @@ -0,0 +1,59 @@ +import { CID } from "multiformats/cid" +import * as siwe from "siwe" + +import { assert } from "@canvas-js/utils" +import type { SIWFSessionData, SIWFMessage } from "./types.js" + +export function validateSIWFSessionData(authorizationData: unknown): authorizationData is SIWFSessionData { + if (authorizationData === undefined || authorizationData === null) { + return false + } else if ( + typeof authorizationData === "boolean" || + typeof authorizationData === "number" || + typeof authorizationData === "string" + ) { + return false + } else if (CID.asCID(authorizationData) !== null) { + return false + } else if (authorizationData instanceof Uint8Array) { + return false + } else if (Array.isArray(authorizationData)) { + return false + } + + const { + custodyAddress, + fid, + signature, + siweDomain, + siweUri, + siweNonce, + siweVersion, + siweChainId, + siweIssuedAt, + siweExpirationTime, + siweNotBefore, + } = authorizationData as Record + return ( + signature instanceof Uint8Array && + typeof custodyAddress === "string" && + typeof fid === "string" && + typeof siweUri === "string" && + typeof siweDomain === "string" && + typeof siweNonce === "string" && + typeof siweVersion === "string" && + typeof siweChainId === "number" && + typeof siweIssuedAt === "string" && + (typeof siweExpirationTime === "string" || siweExpirationTime === null) && + (typeof siweNotBefore === "string" || siweNotBefore === null) + ) +} + +export const addressPattern = /^did:pkh:farcaster:(0x[a-fA-F0-9]+)$/ + +export function parseAddress(address: string): { address: `0x${string}` } { + const result = addressPattern.exec(address) + assert(result !== null) + const [_, addressResult] = result + return { address: addressResult as `0x${string}` } +} diff --git a/packages/chain-ethereum/test/SIWFSigner.test.ts b/packages/chain-ethereum/test/SIWFSigner.test.ts new file mode 100644 index 000000000..2a89f737b --- /dev/null +++ b/packages/chain-ethereum/test/SIWFSigner.test.ts @@ -0,0 +1,112 @@ +import test from "ava" +import { ed25519 } from "@canvas-js/signatures" + +import { SIWFSigner, validateSIWFSessionData } from "@canvas-js/chain-ethereum" +import { Action, Session } from "@canvas-js/interfaces" +import { SIWFSessionData } from "../src/siwf/types.js" +import { getBytes } from "ethers" + +const exampleMessage = `6adf-66-65-178-244.ngrok-free.app wants you to sign in with your Ethereum account: +0x2bbaEe8900bb1664B1C83a3A4F142cDBF2224fb8 + +Farcaster Auth + +URI: https://6adf-66-65-178-244.ngrok-free.app/login +Version: 1 +Chain ID: 10 +Nonce: Qi6dyWgDoxbhx8DBa +Issued At: 2025-01-21T21:51:55.722Z +Request ID: authorize:chat-example.canvas.xyz:did:key:z6MkqDpgypYLmKYiM6b3XXse4PN9AWELvFfbeoKXsa3tQGCe +Resources: +- farcaster://fid/144` + +const exampleCustodyAddress = "0x2bbaEe8900bb1664B1C83a3A4F142cDBF2224fb8" +const exampleSignature = + "0x454bc2f02140571af05a0a5842917163af1d638195add3b63617228d41e55b551ad78638194b2c2167db08e0060245b2a03cf2882bb8e651e666d9fcef70a7321c" +const exampleSignatureParams = { + domain: "6adf-66-65-178-244.ngrok-free.app", + nonce: "Qi6dyWgDoxbhx8DBa", + requestId: "authorize:chat-example.canvas.xyz:did:key:z6MkqDpgypYLmKYiM6b3XXse4PN9AWELvFfbeoKXsa3tQGCe", + siweUrl: "https://6adf-66-65-178-244.ngrok-free.app/login", +} + +const exampleDelegatePrivateKey = "0x2fde6ea1538eb2f3a4f01849572307012df1b3d4e0ec4898bd5e2198099866e8" + +test("create and verify session using external signature", async (t) => { + const topic = "chat-example.canvas.xyz" + + // construct a requestId, which the user will pass to Farcaster to generate a SIWF message + const requestId = await SIWFSigner.getSIWFRequestId(topic, exampleDelegatePrivateKey) + t.is(requestId, exampleSignatureParams.requestId) + + // ... ... + + // parse the SIWF message returned from the Farcaster relay + const { authorizationData, topic: parsedTopic, custodyAddress: parsedCustodyAddress } = SIWFSigner.parseSIWFMessage( + exampleMessage, + exampleSignature, + ) + t.is(parsedTopic, topic) + t.is(parsedCustodyAddress, exampleCustodyAddress) + + // validate the SIWF message matches data returned from the Farcaster relay + t.is(authorizationData.siweDomain, exampleSignatureParams.domain) + t.is(authorizationData.siweNonce, exampleSignatureParams.nonce) + t.is(authorizationData.siweUri, exampleSignatureParams.siweUrl) + + // construct a SIWF signer + const signer = new SIWFSigner({ + custodyAddress: exampleCustodyAddress, + privateKey: exampleDelegatePrivateKey.slice(2), + }) + + // create a canvas session based on the SIWF message + const timestamp = new Date(authorizationData.siweIssuedAt).valueOf() + const { payload, signer: delegateSigner } = await signer.newSIWFSession( + topic, + authorizationData, + timestamp, + getBytes(exampleDelegatePrivateKey), + ) + const session: Session = payload + + // verify the session + t.notThrows(() => signer.verifySession(topic, session)) + + // manually create and validate session message + const sessionMessage = { topic, clock: 1, parents: [], payload: session } + const sessionSignature = await delegateSigner.sign(sessionMessage) + t.notThrows(() => ed25519.verify(sessionSignature, sessionMessage)) + + // manually create and validate action message + const action: Action = { + type: "action", + did: session.did, + name: "foo", + args: [7], + context: { + timestamp: session.context.timestamp, + }, + } + const actionMessage = { topic, clock: 1, parents: [], payload: action } + const actionSignature = await delegateSigner.sign(actionMessage) + t.notThrows(() => ed25519.verify(actionSignature, actionMessage)) + + // creating or verifying a session with an invalid topic should fail + const topic2 = "chat-example2.canvas.xyz" + await t.throwsAsync(async () => signer.newSIWFSession(topic2, authorizationData, timestamp, getBytes(exampleDelegatePrivateKey))) + await t.throwsAsync(async () => signer.verifySession(topic2, session)) +}) + +test("reject invalid siwf message", async (t) => { + const topic = "example:signer" + const { authorizationData } = SIWFSigner.parseSIWFMessage(exampleMessage, exampleSignature) + const timestamp = new Date(authorizationData.siweIssuedAt).valueOf() + + const signer = new SIWFSigner({ + custodyAddress: exampleCustodyAddress, + privateKey: exampleDelegatePrivateKey.slice(2), + }) + + await t.throwsAsync(async () => signer.newSIWFSession(topic, authorizationData, timestamp, getBytes(exampleDelegatePrivateKey))) +}) diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 74f48b1b8..cdb430c65 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -25,7 +25,7 @@ import { MIN_CONNECTIONS, MAX_CONNECTIONS } from "@canvas-js/core/constants" import { NetworkServer } from "@canvas-js/gossiplog/server" import { defaultBootstrapList } from "@canvas-js/gossiplog/bootstrap" -import { SIWESigner } from "@canvas-js/chain-ethereum" +import { SIWESigner, Eip712Signer, SIWFSigner } from "@canvas-js/chain-ethereum" import { ATPSigner } from "@canvas-js/chain-atp" import { CosmosSigner } from "@canvas-js/chain-cosmos" import { SubstrateSigner } from "@canvas-js/chain-substrate" @@ -164,7 +164,15 @@ export async function handler(args: Args) { console.log(`${chalk.gray("[canvas] Starting app on topic")} ${chalk.whiteBright(topic)}`) - const signers = [new SIWESigner(), new ATPSigner(), new CosmosSigner(), new SubstrateSigner(), new SolanaSigner()] + const signers = [ + new SIWESigner(), + new Eip712Signer(), + new SIWFSigner(), + new ATPSigner(), + new CosmosSigner(), + new SubstrateSigner(), + new SolanaSigner(), + ] const app = await Canvas.initialize({ path: location, topic, contract, signers }) app.addEventListener("message", ({ detail: { id, message } }) => { diff --git a/packages/interfaces/src/SessionSigner.ts b/packages/interfaces/src/SessionSigner.ts index 7a39d2825..e627c8785 100644 --- a/packages/interfaces/src/SessionSigner.ts +++ b/packages/interfaces/src/SessionSigner.ts @@ -28,14 +28,24 @@ export interface SessionSigner { getSession: ( topic: string, options?: { did?: DidIdentifier } | { address: string }, - ) => Awaitable<{ payload: Session; signer: Signer | Snapshot> } | null> + ) => Awaitable<{ + payload: Session + signer: Signer | Snapshot> + } | null> newSession: ( topic: string, - ) => Awaitable<{ payload: Session; signer: Signer | Snapshot> }> + ) => Awaitable<{ + payload: Session + signer: Signer | Snapshot> + }> /** - * Verify that `session.data` authorizes `session.publicKey` - * to take actions on behalf of the user `session.did` + * Request a signature from the signer's Wallet, and use it to return a session. + */ + authorize(data: AbstractSessionData, authorizationData?: AuthorizationData): Awaitable> + + /** + * Verify that `session.data` authorizes `session.publicKey` to take actions on behalf of the user `session.did`. */ verifySession: (topic: string, session: Session) => Awaitable diff --git a/packages/signatures/src/AbstractSessionSigner.ts b/packages/signatures/src/AbstractSessionSigner.ts index 2d8688280..ee6d80b94 100644 --- a/packages/signatures/src/AbstractSessionSigner.ts +++ b/packages/signatures/src/AbstractSessionSigner.ts @@ -20,8 +20,11 @@ export interface AbstractSessionSignerOptions { sessionDuration?: number | null } -export abstract class AbstractSessionSigner - implements SessionSigner +export abstract class AbstractSessionSigner< + AuthorizationData, + WalletAddress extends string = string, + AuthorizationContext = never, +> implements SessionSigner { public readonly target = target public readonly sessionDuration: number | null @@ -57,17 +60,23 @@ export abstract class AbstractSessionSigner by asking the signer's wallet to + * produce an authorization signature. + */ public abstract authorize(data: AbstractSessionData): Awaitable> - /* - * Create a new session and cache it for the given `topic`. + /** + * Start a new session, either by requesting a signature from a wallet right now, + * or by using a provided AuthorizationData and timestamp (for services like Farcaster). */ public async newSession( - topic: string, + topic: string ): Promise<{ payload: Session; signer: Signer | Snapshot> }> { const signer = this.scheme.create() const did = await this.getDid() - const session = await this.authorize({ + + const sessionData = { topic, did, publicKey: signer.publicKey, @@ -75,7 +84,8 @@ export abstract class AbstractSessionSigner from a preexisting, externally + * provided AuthorizationData. + */ + public async getSessionFromAuthorizationData( + data: AbstractSessionData, + authorizationData: AuthorizationData, + ): Promise> { + const { + did, + publicKey, + topic, + context: { duration, timestamp }, + } = data + + const session: Session = { + type: "session", + did: did, + publicKey: publicKey, + authorizationData, + context: duration ? { duration, timestamp } : { timestamp }, + } + + await this.verifySession(topic, session) + + return session + } + /* * Get an existing session for `topic`. You may also provide a specific DID to check * if a session exists for that specific address.