diff --git a/README.md b/README.md index fef5fee..d0019f5 100644 --- a/README.md +++ b/README.md @@ -333,11 +333,21 @@ The backend validates all environment variables **at startup**. If a required va ### Webhook signing -- Header: `X-Webhook-Signature` +- Header: `X-StellarStream-Signature` - Format: `sha256=` - Digest input: raw JSON request body string - Algorithm: HMAC-SHA256 using `WEBHOOK_SIGNING_SECRET` +To verify a delivery, compute `sha256=` + HMAC-SHA256 of the raw request body using your `WEBHOOK_SIGNING_SECRET` and compare it to the `X-StellarStream-Signature` header value using a constant-time comparison. + +Example (Node.js): +```js +const { createHmac, timingSafeEqual } = require("crypto"); +const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"); +const received = req.headers["x-stellarstream-signature"]; +const valid = timingSafeEqual(Buffer.from(expected), Buffer.from(received)); +``` + If `WEBHOOK_DESTINATION_URL` is set without `WEBHOOK_SIGNING_SECRET`, webhooks are delivered unsigned and a warning is logged at startup. ### Startup validation behaviour diff --git a/backend/src/services/webhook.test.ts b/backend/src/services/webhook.test.ts index b1501cb..1c67b9d 100644 --- a/backend/src/services/webhook.test.ts +++ b/backend/src/services/webhook.test.ts @@ -1,11 +1,3 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { getRetryDelaySeconds, triggerWebhook, getDeadLetters } from "./webhook"; -import { initDb, getDb } from "./db"; -import fs from "fs"; -import path from "path"; - -const TEST_DB_PATH = path.join(__dirname, "..", "..", "data", "webhook-test.db"); - describe("Webhook Retry Logic", () => { it("should return correct retry delays", () => { diff --git a/backend/src/services/webhook.ts b/backend/src/services/webhook.ts index fb0733a..83cbc24 100644 --- a/backend/src/services/webhook.ts +++ b/backend/src/services/webhook.ts @@ -1,5 +1,10 @@ +import { createHmac } from "crypto"; import { getDb } from "./db"; +export function computeSignature(secret: string, body: string): string { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + const MAX_RETRIES = 5; const RETRY_DELAYS = [5, 15, 60, 300, 900]; // seconds: 5s, 15s, 60s, 300s, 900s diff --git a/backend/src/services/webhookWorker.ts b/backend/src/services/webhookWorker.ts index b875f21..a96de65 100644 --- a/backend/src/services/webhookWorker.ts +++ b/backend/src/services/webhookWorker.ts @@ -1,7 +1,6 @@ import axios from "axios"; import { getDb } from "./db"; -import { getRetryDelaySeconds } from "./webhook"; -import { getWebhookHeaders } from "./webhookSignature"; + let isProcessing = false; let pollingInterval: NodeJS.Timeout | null = null; @@ -44,7 +43,7 @@ export const processWebhookQueue = async () => { timestamp, }; const bodyString = JSON.stringify(body); - const headers = getWebhookHeaders(bodyString, process.env.WEBHOOK_SIGNING_SECRET); + await axios.post(url, bodyString, { headers }); success = true;