diff --git a/.env.example b/.env.example index 12b1ed1..7ab4380 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,11 @@ # Get it via `gcloud projects list --filter="name:governance-watchdog*" --format="value(projectId)")` GCP_PROJECT_ID= -# Required for the function to be able to look up the Discord Webhook URL and Telegram Bot Token in GCP Secret Manager. -# Get it via `gcloud secrets list` -DISCORD_WEBHOOK_URL_SECRET_ID= -TELEGRAM_BOT_TOKEN_SECRET_ID= +# Required for the function to be able to look up secrets in GCP Secret Manager. +# Get them via `gcloud secrets list` +DISCORD_WEBHOOK_URL_SECRET_ID=discord-webhook-url +QUICKNODE_SECURITY_TOKEN_SECRET_ID=quicknode-security-token +TELEGRAM_BOT_TOKEN_SECRET_ID=telegram-bot-token # Get it via inviting @MissRose_bot to the telegram group and then using the `/id` command (please remove the bot after you're done) TELEGRAM_CHAT_ID= diff --git a/infra/cloud_function.tf b/infra/cloud_function.tf index de6a4e9..a83a980 100644 --- a/infra/cloud_function.tf +++ b/infra/cloud_function.tf @@ -34,10 +34,11 @@ resource "google_cloudfunctions2_function" "watchdog_notifications" { environment_variables = { # Necessary for the function to be able to find the secrets in Secret Manager - GCP_PROJECT_ID = module.bootstrap.seed_project_id - DISCORD_WEBHOOK_URL_SECRET_ID = google_secret_manager_secret.discord_webhook_url.secret_id - TELEGRAM_BOT_TOKEN_SECRET_ID = google_secret_manager_secret.telegram_bot_token.secret_id - TELEGRAM_CHAT_ID = var.telegram_chat_id + GCP_PROJECT_ID = module.bootstrap.seed_project_id + DISCORD_WEBHOOK_URL_SECRET_ID = google_secret_manager_secret.discord_webhook_url.secret_id + TELEGRAM_BOT_TOKEN_SECRET_ID = google_secret_manager_secret.telegram_bot_token.secret_id + TELEGRAM_CHAT_ID = var.telegram_chat_id + QUICKNODE_SECURITY_TOKEN_SECRET_ID = google_secret_manager_secret.quicknode_security_token.secret_id # Logs execution ID for easier debugging => https://cloud.google.com/functions/docs/monitoring/logging#viewing_runtime_logs LOG_EXECUTION_ID = true diff --git a/infra/secret-manager.tf b/infra/secret-manager.tf index ba0af8c..5e3db6b 100644 --- a/infra/secret-manager.tf +++ b/infra/secret-manager.tf @@ -16,8 +16,6 @@ resource "google_secret_manager_secret_version" "discord_webhook_url" { } # Creates a new secret for the Telegram Bot Token. -# Terraform will try to look up the webhook URL from terraform.tfvars, -# and if it can't find it locally it will prompt the user to enter it manually. resource "google_secret_manager_secret" "telegram_bot_token" { project = module.bootstrap.seed_project_id secret_id = "telegram-bot-token" @@ -31,3 +29,18 @@ resource "google_secret_manager_secret_version" "telegram_bot_token" { secret = google_secret_manager_secret.telegram_bot_token.id secret_data = var.telegram_bot_token } + +# Creates a new secret for the Quicknode Security Token which is used to verify that requests to the Cloud Function are coming from Quicknode. +resource "google_secret_manager_secret" "quicknode_security_token" { + project = module.bootstrap.seed_project_id + secret_id = "quicknode-security-token" + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "quicknode_security_token" { + secret = google_secret_manager_secret.quicknode_security_token.id + secret_data = quicknode_destination.destination.token +} diff --git a/src/config.ts b/src/config.ts index 855cd9f..7544401 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { JSONSchemaType, envSchema } from "env-schema"; export interface Env { GCP_PROJECT_ID: string; DISCORD_WEBHOOK_URL_SECRET_ID: string; + QUICKNODE_SECURITY_TOKEN_SECRET_ID: string; TELEGRAM_BOT_TOKEN_SECRET_ID: string; TELEGRAM_CHAT_ID: string; } @@ -12,12 +13,14 @@ const schema: JSONSchemaType = { required: [ "GCP_PROJECT_ID", "DISCORD_WEBHOOK_URL_SECRET_ID", + "QUICKNODE_SECURITY_TOKEN_SECRET_ID", "TELEGRAM_BOT_TOKEN_SECRET_ID", "TELEGRAM_CHAT_ID", ], properties: { GCP_PROJECT_ID: { type: "string" }, DISCORD_WEBHOOK_URL_SECRET_ID: { type: "string" }, + QUICKNODE_SECURITY_TOKEN_SECRET_ID: { type: "string" }, TELEGRAM_BOT_TOKEN_SECRET_ID: { type: "string" }, TELEGRAM_CHAT_ID: { type: "string" }, }, diff --git a/src/get-quicknode-security-token.ts b/src/get-quicknode-security-token.ts new file mode 100644 index 0000000..6f104bd --- /dev/null +++ b/src/get-quicknode-security-token.ts @@ -0,0 +1,28 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import config from "./config.js"; + +/** + * Get the Quicknode Security Token from GCloud Secret Manager + * + * NOTE: This will fail locally because the local function will lack the necessary permissions to access the secret. + * That's why read the webhook URL from our .env file when running locally. We could probably make it work by having + * the local function impersonate the service account used by the function in GCP, but that was a rabbit hole I didn't + * want to go down when a simple .env approach also works for local testing. + */ +export default async function getQuicknodeSecurityToken(): Promise { + const secretManager = new SecretManagerServiceClient(); + const secretFullResourceName = `projects/${config.GCP_PROJECT_ID}/secrets/${config.QUICKNODE_SECURITY_TOKEN_SECRET_ID}/versions/latest`; + const [version] = await secretManager.accessSecretVersion({ + name: secretFullResourceName, + }); + + const securityToken = version.payload?.data?.toString(); + + if (!securityToken) { + throw new Error( + "Failed to retrieve Quicknode security token from secret manager", + ); + } + + return securityToken; +} diff --git a/src/index.ts b/src/index.ts index c6348a8..8c59336 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,17 @@ import type { import parseTransactionReceipts from "./parse-transaction-receipts"; import sendDiscordNotification from "./send-discord-notification"; import sendTelegramNotification from "./send-telegram-notification"; +// import validateRequestOrigin from "./validate-request-origin"; export const watchdogNotifier: HttpFunction = async ( req: Request, res: Response, ) => { try { + // TODO: Activate this after we've verified it's working via SortedOracle events + // if (process.env.NODE_ENV !== "development") { + // await validateRequestOrigin(req); + // } const parsedEvents = parseTransactionReceipts(req.body); for (const parsedEvent of parsedEvents) { diff --git a/src/validate-request-origin.ts b/src/validate-request-origin.ts new file mode 100644 index 0000000..cc4500c --- /dev/null +++ b/src/validate-request-origin.ts @@ -0,0 +1,48 @@ +import type { Request } from "@google-cloud/functions-framework"; +import crypto from "crypto"; +import getQuicknodeSecurityToken from "./get-quicknode-security-token"; + +export default async function validateRequestOrigin(req: Request) { + const quicknodeSecurityToken = await getQuicknodeSecurityToken(); + const givenSignature = req.headers["x-qn-signature"]; + const nonce = req.headers["x-qn-nonce"]; + const contentHash = req.headers["x-qn-content-hash"]; + const timestamp = req.headers["x-qn-timestamp"]; + + if (!nonce || typeof nonce !== "string") { + console.error("No valid quicknode nonce found in request headers:", nonce); + return; + } + + if (!contentHash || typeof contentHash !== "string") { + console.error( + "No valid quicknode content hash found in request headers:", + contentHash, + ); + return; + } + + if (!timestamp || typeof timestamp !== "string") { + console.error( + "No valid quicknode timestamp found in request headers:", + contentHash, + ); + return; + } + + const hmac = crypto.createHmac("sha256", quicknodeSecurityToken); + hmac.update(`${nonce}${contentHash}${timestamp}`); + + const expectedSignature = hmac.digest("base64"); + + if (givenSignature === expectedSignature) { + console.log( + "The signature given matches the expected signature and is valid.", + ); + return; + } else { + throw new Error( + "Signature validation failed for the request's Quicknode signature header", + ); + } +}