diff --git a/.env b/.env deleted file mode 100644 index 28b258a..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -COLLABLAND_ACTION_PUBLIC_KEY="7fe0a458caca2901f62e029a9da29f65b0915fc837eedc6db902be3aaceaffcc" -SKIP_VERIFICATION=true \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e6f0e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SKIP_VERIFICATION= +NODE_ENV= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index c268e32..0de68f8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,4 +17,5 @@ module.exports = { parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, + ignorePatterns: ["dist/**"], }; diff --git a/.gitignore b/.gitignore index bea514d..0ce2085 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .idea dist -.DS_Store \ No newline at end of file +.DS_Store +.env diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ed4d133 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotenv.enableAutocloaking": false +} diff --git a/README.md b/README.md index 356311b..3af94c3 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,10 @@ The repository serves as a Express.js template for implementing Collab.Land acti ## **Contributing** 🫶 -- Please go through the following article [[**Link**]()] to understand the deep technical details regarding building on the Collab.Land actions platform. +- Please go through the following article [[**Link**](https://dev.collab.land/docs/upstream-integrations/build-a-custom-action)] to understand the deep technical details regarding building on the Collab.Land actions platform. - In order to change the slash commands for the actions, try editing the `MiniAppManifest` models mentioned in the metadata route handlers [[Here 👀]](src/routes/hello-action.ts#L86) - In order to change the logic which runs on the slash commands, try changing the `handle()` function mentioned in the interactions route handlers [[Here 👀]](src/routes/hello-action.ts#L23) --- +
Built with ❤️ and 🤝 by Collab.Land
diff --git a/package-lock.json b/package-lock.json index 315ee4c..a9a0fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@collabland/models": "^0.22.1", "@discordjs/rest": "^1.5.0", "body-parser": "^1.20.1", + "bs58": "^5.0.0", "cookie-parser": "~1.4.4", "discord.js": "^14.7.1", "dotenv": "^16.0.3", diff --git a/package.json b/package.json index 2d85466..fb77734 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@collabland/models": "^0.22.1", "@discordjs/rest": "^1.5.0", "body-parser": "^1.20.1", + "bs58": "^5.0.0", "cookie-parser": "~1.4.4", "discord.js": "^14.7.1", "dotenv": "^16.0.3", diff --git a/src/bin/www.ts b/src/bin/www.ts index cf18ab0..4441644 100755 --- a/src/bin/www.ts +++ b/src/bin/www.ts @@ -4,6 +4,7 @@ import app from "../app"; import * as http from "http"; import * as dotenv from "dotenv"; // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import import debug from "debug"; +import { SignatureVerifier } from "../helpers/verify"; dotenv.config(); /** @@ -26,12 +27,16 @@ app.set("port", port); const server = http.createServer(app); /** - * Listen on provided port, on all network interfaces. + * Initialize all services, listen on provided port, on all network interfaces. */ -server.listen(port); -server.on("error", onError); -server.on("listening", onListening); +Promise.all([SignatureVerifier.initVerifier()]) + .then((_) => { + server.listen(port); + server.on("error", onError); + server.on("listening", onListening); + }) + .catch((e) => onError(e)); /** * Normalize a port into a number, string, or false. @@ -76,6 +81,7 @@ function onError(error: any) { break; default: throw error; + process.exit(1); } } diff --git a/src/helpers/verify.ts b/src/helpers/verify.ts index ec87457..9be036d 100644 --- a/src/helpers/verify.ts +++ b/src/helpers/verify.ts @@ -1,49 +1,98 @@ import { utils, Wallet } from "ethers"; import nacl from "tweetnacl"; import { Request, Response } from "express"; +import { decode } from "bs58"; import { ActionEcdsaSignatureHeader, ActionEd25519SignatureHeader, ActionSignatureTimestampHeader, } from "../constants"; -import { debugFactory } from "@collabland/common"; +import { + AnyType, + debugFactory, + getFetch, + handleFetchResponse, + HttpErrors, +} from "@collabland/common"; +const fetch = getFetch(); const debug = debugFactory("SignatureVerifier"); +type CollabLandConfig = { + jwtPublicKey: string; + discordClientId: string; + actionEcdsaPublicKey: string; + actionEd25519PublicKey: string; +}; + export class SignatureVerifier { + private static ECDSAPublicKey: string; + private static ED25519PublicKey: string; + + static async initVerifier() { + const apiUrl = `https://api${ + process.env.NODE_ENV === "production" ? "" : "-qa" + }.collab.land/config`; + const keysResponse = await fetch(apiUrl); + const keys = await handleFetchResponse( + keysResponse, + 200, + { + customErrorMessage: `Error in fetching collab.land config from URL: ${apiUrl}`, + } + ); + SignatureVerifier.ECDSAPublicKey = keys.actionEcdsaPublicKey; + SignatureVerifier.ED25519PublicKey = Buffer.from( + decode(keys.actionEd25519PublicKey) + ).toString("hex"); + debug("API URL for Collab.Land Config:", apiUrl); + debug("SingatureVerifier Initialized"); + } verify(req: Request, res: Response) { if (!process.env.SKIP_VERIFICATION) { - const ecdsaSignature = req.header(ActionEcdsaSignatureHeader); - const ed25519Signature = req.header(ActionEd25519SignatureHeader); - const signatureTimestamp: number = parseInt( - req.header(ActionSignatureTimestampHeader) ?? "0" - ); - const body = JSON.stringify(req.body); - const publicKey = this.getPublicKey(); - const signature = ecdsaSignature ?? ed25519Signature; - if (!signature) { - res.status(401); - res.send({ - message: `${ActionEcdsaSignatureHeader} or ${ActionEd25519SignatureHeader} header is required`, - }); - return; - } - if (!publicKey) { - res.status(401); - res.send({ - message: `Public key is not set.`, - }); - return; + try { + debug("Verifying signature..."); + const ecdsaSignature = req.header(ActionEcdsaSignatureHeader); + const ed25519Signature = req.header(ActionEd25519SignatureHeader); + const signatureTimestamp: number = parseInt( + req.header(ActionSignatureTimestampHeader) ?? "0" + ); + const body = JSON.stringify(req.body); + const signature = ecdsaSignature ?? ed25519Signature; + if (!signature) { + throw new HttpErrors[401]( + `${ActionEcdsaSignatureHeader} or ${ActionEd25519SignatureHeader} header is required` + ); + } + const signatureType = + signature === ecdsaSignature ? "ecdsa" : "ed25519"; + const publicKey = this.getPublicKey(signatureType); + if (!publicKey) { + throw new HttpErrors[401](`Public key is not set.`); + } + this.verifyRequest( + body, + signatureTimestamp, + signature, + publicKey, + signatureType + ); + return true; + } catch (err) { + if (HttpErrors.isHttpError(err)) { + res.status(err.statusCode).json({ + message: err.message, + }); + return false; + } else { + res.status(403).json({ + message: "Unauthorized", + }); + return false; + } } - const signatureType = signature === ecdsaSignature ? "ecdsa" : "ed25519"; - - this.verifyRequest( - body, - signatureTimestamp, - signature, - publicKey, - signatureType - ); + } else { + return true; } } @@ -63,8 +112,10 @@ export class SignatureVerifier { }; } - private getPublicKey() { - return process.env.COLLABLAND_ACTION_PUBLIC_KEY; + private getPublicKey(signatureType: "ecdsa" | "ed25519") { + return signatureType === "ecdsa" + ? SignatureVerifier.ECDSAPublicKey + : SignatureVerifier.ED25519PublicKey; } private verifyRequest( @@ -76,7 +127,9 @@ export class SignatureVerifier { ) { const delta = Math.abs(Date.now() - signatureTimestamp); if (delta >= 5 * 60 * 1000) { - throw new Error("Invalid request - signature timestamp is expired."); + throw new HttpErrors[403]( + "Invalid request - signature timestamp is expired." + ); } const msg = signatureTimestamp + body; if (signatureType === "ed25519") { @@ -109,13 +162,13 @@ export class SignatureVerifier { Buffer.from(publicKey, "hex") ); debug("Signature verified: %s", verified); - } catch (err: any) { + } catch (err: AnyType) { verified = false; debug(err.message); } if (!verified) { - throw new Error( + throw new HttpErrors[403]( "Invalid request - Ed25519 signature cannot be verified." ); } @@ -148,7 +201,9 @@ export class SignatureVerifier { if (!verified) { debug("Invalid signature: %s, body: %s", signature, body); - throw new Error("Invalid request - Ecdsa signature cannot be verified."); + throw new HttpErrors[403]( + "Invalid request - Ecdsa signature cannot be verified." + ); } return verified; } diff --git a/src/routes/button-action.ts b/src/routes/button-action.ts index 2cb8d26..4ac4b47 100644 --- a/src/routes/button-action.ts +++ b/src/routes/button-action.ts @@ -68,6 +68,7 @@ router.get("/metadata", function (req, res) { version: { name: "0.0.1" }, website: "https://collab.land", description: "An example Collab.Land action", + shortDescription: "A short description for the miniapp card" }); const metadata: DiscordActionMetadata = { /** @@ -112,9 +113,11 @@ router.get("/metadata", function (req, res) { router.post("/interactions", async function (req, res) { const verifier = new SignatureVerifier(); - verifier.verify(req, res); - const result = await handle(req.body); - res.send(result); + const verified = verifier.verify(req, res); + if (verified) { + const result = await handle(req.body); + res.send(result); + } }); export default router; diff --git a/src/routes/hello-action.ts b/src/routes/hello-action.ts index e396251..7a430f1 100644 --- a/src/routes/hello-action.ts +++ b/src/routes/hello-action.ts @@ -139,9 +139,11 @@ router.get("/metadata", function (req, res) { router.post("/interactions", async function (req, res) { const verifier = new SignatureVerifier(); - verifier.verify(req, res); - const result = await handle(req.body); - res.send(result); + const verified = verifier.verify(req, res); + if (verified) { + const result = await handle(req.body); + res.send(result); + } }); export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 0f5d355..8dfad5d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,4 +2,4 @@ import helloAction from "./hello-action"; import buttonAction from "./button-action"; import popupAction from "./popup-action"; -export default {helloAction, buttonAction, popupAction}; +export default { helloAction, buttonAction, popupAction }; diff --git a/src/routes/popup-action.ts b/src/routes/popup-action.ts index ff26b41..92f6677 100644 --- a/src/routes/popup-action.ts +++ b/src/routes/popup-action.ts @@ -120,9 +120,11 @@ router.get("/metadata", function (req, res) { router.post("/interactions", async function (req, res) { const verifier = new SignatureVerifier(); - verifier.verify(req, res); - const result = await handle(req.body); - res.send(result); + const verified = verifier.verify(req, res); + if (verified) { + const result = await handle(req.body); + res.send(result); + } }); export default router;