From 0e024469d14bd95153babe13e1a2b84bfd2473fc Mon Sep 17 00:00:00 2001 From: TheWeirdDee Date: Mon, 17 Aug 2026 13:29:06 +0100 Subject: [PATCH] feat(security): sign and verify inbound webhook callbacks (HMAC + timestamp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/sep24/callback — the endpoint an anchor uses to report deposit/ withdrawal status — accepted any unauthenticated POST body and applied it directly (sep24Service.handleAnchorCallback), including flipping a transaction to "completed". Any third party who could reach the endpoint could forge that callback. - New verifyInboundWebhookSignature(endpoint) middleware, applied to the SEP-24 callback route. Requires X-Signature (hex HMAC-SHA256 of the raw request body) and X-Timestamp (Unix seconds), in order: 1. both headers present 2. timestamp within ±WEBHOOK_REPLAY_WINDOW_SECONDS (default 300s) of now 3. signature matches HMAC-SHA256(secret, rawBody) for at least one of the endpoint's active secrets, via crypto.timingSafeEqual (never ===) 4. this exact signature hasn't been used before (atomic replay-nonce check, run last so a caller without the secret can't use it to pollute the nonce store) Rejections are a generic 401 — the response never reveals which check failed. - Needs the exact raw request bytes (not a JSON.stringify() of req.body, which isn't guaranteed byte-identical to what was signed). bodyParsing.js now captures this via express.json()'s verify callback onto req.rawBody, exposed through a jsonBodyParser() factory so test apps use the identical parser config as production. - New inbound_webhook_secrets table + inboundWebhookSecretService.js: per-endpoint secrets stored as AES-256-GCM ciphertext (never plaintext), plus a keyed HMAC-SHA256 fingerprint — the same at-rest pattern already used for outbound webhook secrets (webhookService.js). Multiple secrets can be active per endpoint at once, which is what makes rotation with a grace period possible: rotateSecret(endpoint, { graceSeconds }) keeps the old secret valid for a bounded window instead of invalidating it the instant the new one is generated, and revokeSecret() cuts a specific secret off early regardless of any grace period. - Extracted the keyed-hash helper (hashSecret/WEBHOOK_SECRET_KEY) out of webhookService.js into utils/webhookSecretHash.js so it has no dependency on @stellar/stellar-sdk, which webhookService.js only needs for unrelated Horizon SSE streaming. webhookService.js re-exports both for backward compatibility. - Server startup provisions a secret for sep24_callback if one doesn't exist yet (ensureSecretExists, idempotent) and logs it once so an operator can configure it on the anchor's side without a separate manual step; use rotateSecret()/revokeSecret() afterwards. - Replay-nonce store reuses cacheService.js (Redis, with an always-on in-memory LRU fallback) via a new setIfNotExists() — a single atomic SET...NX operation, not a get()-then-set() with a race window. - New AUTH_INVALID_WEBHOOK_SIGNATURE (401) error code in the shared catalogue, consistent with the existing AUTH_* codes. - 18 tests in backend/__tests__/webhookSignature.test.js (issue asked for ≥8): valid signature, missing X-Signature, missing X-Timestamp, malformed signature, tampered body, wrong secret, stale timestamp, future timestamp, non-numeric timestamp, replay, no active secret, generic error body, plus secret-service coverage (never-plaintext storage, idempotent bootstrap, rotation with/without grace period, early revocation, metadata-only listing). Runs against a real Express app + a real throwaway migrated SQLite DB, not mocks. Also found, but did not fix (separate, unrelated, already broken on main): webhookService.js's registerWebhook() inserts into a `secret` column that migration 003_webhooks.js never created (only `secret_hash` exists) — the outbound POST /api/webhooks registration path throws "no such column: secret" against the real migrated schema. Reproduces on a clean checkout with no changes from this branch; several existing tests (webhookDeliveryRetry.test.js) already fail because of it. Flagging for a separate fix. Closes #631 --- backend/.env.example | 6 + backend/__tests__/webhookSignature.test.js | 313 ++++++++++++++++++ .../migrations/027_inbound_webhook_secrets.js | 38 +++ backend/src/middleware/bodyParsing.js | 32 +- .../verifyInboundWebhookSignature.js | 141 ++++++++ backend/src/routes/sep24.js | 9 +- backend/src/server.js | 16 + backend/src/services/cacheService.js | 56 ++++ .../services/inboundWebhookSecretService.js | 176 ++++++++++ backend/src/services/webhookService.js | 37 +-- backend/src/utils/webhookSecretHash.js | 49 +++ shared/errorCodes.js | 5 + 12 files changed, 845 insertions(+), 33 deletions(-) create mode 100644 backend/__tests__/webhookSignature.test.js create mode 100644 backend/migrations/027_inbound_webhook_secrets.js create mode 100644 backend/src/middleware/verifyInboundWebhookSignature.js create mode 100644 backend/src/services/inboundWebhookSecretService.js create mode 100644 backend/src/utils/webhookSecretHash.js diff --git a/backend/.env.example b/backend/.env.example index 908600f1..026f89a5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -109,6 +109,12 @@ TURRETS_EVALUATION_INTERVAL_MS=30000 # Generate: openssl rand -hex 32 # WEBHOOK_SECRET_KEY=change-me-to-a-long-random-secret +# ─── Inbound Webhook Signature Verification ──────────────────────────────────── +# How far (in seconds) an inbound webhook's X-Timestamp header may drift from +# the server's clock before the request is rejected as stale/replayed. +# Applies to POST /api/sep24/callback (see verifyInboundWebhookSignature.js). +# WEBHOOK_REPLAY_WINDOW_SECONDS=300 + # ─── Email Notifications ─────────────────────────────────────────────────────── # Enable or disable email notifications (default: false) NOTIFICATION_EMAIL_ENABLED=false diff --git a/backend/__tests__/webhookSignature.test.js b/backend/__tests__/webhookSignature.test.js new file mode 100644 index 00000000..d05c1eb5 --- /dev/null +++ b/backend/__tests__/webhookSignature.test.js @@ -0,0 +1,313 @@ +/* eslint-env jest */ +/** + * __tests__/webhookSignature.test.js + * + * Inbound webhook signature verification (X-Signature / X-Timestamp HMAC). + * Covers valid, tampered, replayed, missing-header, stale-timestamp, and + * secret-rotation cases end-to-end through a real Express app + real + * (throwaway, migrated) SQLite DB — no mocking of the crypto path itself. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const crypto = require("crypto"); +const express = require("express"); +const request = require("supertest"); + +// Use a per-test-file throwaway SQLite DB, migrated fresh, BEFORE any module +// that opens db/connection.js is required (mirrors turretsPersistence.test.js). +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "webhook-signature-")); +const DB_FILE = path.join(TMP_DIR, "webhook-signature.db"); +process.env.DB_PROVIDER = "sqlite"; +process.env.DB_FILENAME = DB_FILE; +process.env.NODE_ENV = "test"; +// 64-char hex key required by the AES-256-GCM encryption utility. +process.env.WEBHOOK_ENCRYPTION_KEY = + "aaabbbcccdddeeefff000111222333444555666777888999000aaabbbcccdddee"; +process.env.WEBHOOK_SECRET_KEY = "test-webhook-secret-key-for-hashing-only"; + +const knex = require("../src/db/connection"); +const cacheService = require("../src/services/cacheService"); +const inboundWebhookSecretService = require("../src/services/inboundWebhookSecretService"); +const { verifyInboundWebhookSignature } = require("../src/middleware/verifyInboundWebhookSignature"); +const { jsonBodyParser } = require("../src/middleware/bodyParsing"); + +const ENDPOINT = "test_webhook"; + +function buildApp() { + const app = express(); + app.use(jsonBodyParser()); + app.post("/webhook", verifyInboundWebhookSignature(ENDPOINT), (req, res) => { + res.status(200).json({ received: true }); + }); + return app; +} + +function sign(secret, rawBody) { + return crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); +} + +function nowSeconds() { + return Math.floor(Date.now() / 1000); +} + +beforeAll(async () => { + await knex.migrate.latest(); +}); + +afterAll(async () => { + await knex.destroy(); + try { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +afterEach(() => { + // The replay-nonce store persists across requests within a test via the + // in-memory LRU fallback (no Redis in this test env) — clear it so one + // test's signatures can't accidentally collide with another's. + cacheService.clearLRU(); +}); + +describe("verifyInboundWebhookSignature", () => { + let app; + let secret; + + beforeEach(async () => { + await knex("inbound_webhook_secrets").where({ endpoint: ENDPOINT }).del(); + const created = await inboundWebhookSecretService.createSecret(ENDPOINT); + secret = created.secret; + app = buildApp(); + }); + + it("accepts a request with a valid signature and fresh timestamp", async () => { + const body = { transaction: { id: "tx-1", status: "completed" } }; + const rawBody = JSON.stringify(body); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it("rejects a request with no X-Signature header with 401", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-2" } }); + const res = await request(app) + .post("/webhook") + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("AUTH_INVALID_WEBHOOK_SIGNATURE"); + }); + + it("rejects a request with no X-Timestamp header with 401", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-3" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("AUTH_INVALID_WEBHOOK_SIGNATURE"); + }); + + it("rejects a request with a malformed (non-hex) signature with 401", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-4" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", "not-a-valid-hex-signature!!") + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("rejects a tampered body signed with a valid secret but for different content", async () => { + const signedBody = JSON.stringify({ transaction: { id: "tx-5", status: "pending" } }); + const tamperedBody = JSON.stringify({ transaction: { id: "tx-5", status: "completed" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, signedBody)) // signature for the ORIGINAL body + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(tamperedBody); // but the TAMPERED body is what's actually sent + + expect(res.status).toBe(401); + }); + + it("rejects a signature produced with the wrong secret", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-6" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign("completely-wrong-secret", rawBody)) + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("rejects a stale timestamp outside the replay window", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-7" } }); + const staleTimestamp = nowSeconds() - 3600; // 1 hour ago, window is 5 min + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("X-Timestamp", String(staleTimestamp)) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("rejects a timestamp too far in the future", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-8" } }); + const futureTimestamp = nowSeconds() + 3600; + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("X-Timestamp", String(futureTimestamp)) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("rejects a non-numeric X-Timestamp", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-9" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("X-Timestamp", "not-a-number") + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("accepts the first use of a valid signature but rejects a byte-for-byte replay", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-10", status: "completed" } }); + const headers = { + "X-Signature": sign(secret, rawBody), + "X-Timestamp": String(nowSeconds()), + }; + + const first = await request(app) + .post("/webhook") + .set(headers) + .set("Content-Type", "application/json") + .send(rawBody); + expect(first.status).toBe(200); + + const replay = await request(app) + .post("/webhook") + .set(headers) + .set("Content-Type", "application/json") + .send(rawBody); + expect(replay.status).toBe(401); + }); + + it("rejects every request when the endpoint has no active secret", async () => { + await knex("inbound_webhook_secrets").where({ endpoint: ENDPOINT }).del(); + const rawBody = JSON.stringify({ transaction: { id: "tx-11" } }); + const res = await request(app) + .post("/webhook") + .set("X-Signature", sign(secret, rawBody)) + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + expect(res.status).toBe(401); + }); + + it("does not include which check failed in the response body", async () => { + const rawBody = JSON.stringify({ transaction: { id: "tx-12" } }); + const res = await request(app) + .post("/webhook") + .set("X-Timestamp", String(nowSeconds())) + .set("Content-Type", "application/json") + .send(rawBody); + + const serialized = JSON.stringify(res.body); + expect(serialized).not.toMatch(/missing_headers|stale|replay|signature_mismatch/i); + }); +}); + +describe("inboundWebhookSecretService", () => { + const ROTATION_ENDPOINT = "test_rotation_endpoint"; + + beforeEach(async () => { + await knex("inbound_webhook_secrets").where({ endpoint: ROTATION_ENDPOINT }).del(); + }); + + it("never stores the secret in plaintext", async () => { + const created = await inboundWebhookSecretService.createSecret(ROTATION_ENDPOINT); + const row = await knex("inbound_webhook_secrets").where({ id: created.id }).first(); + + expect(row.secret_encrypted).not.toContain(created.secret); + expect(row.secret_hash).not.toBe(created.secret); + expect(typeof row.secret_encrypted).toBe("string"); + }); + + it("ensureSecretExists is idempotent — a second call is a no-op", async () => { + const first = await inboundWebhookSecretService.ensureSecretExists(ROTATION_ENDPOINT); + const second = await inboundWebhookSecretService.ensureSecretExists(ROTATION_ENDPOINT); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + const active = await inboundWebhookSecretService.getActiveSecrets(ROTATION_ENDPOINT); + expect(active).toHaveLength(1); + }); + + it("rotateSecret with no grace period immediately invalidates the old secret", async () => { + const original = await inboundWebhookSecretService.createSecret(ROTATION_ENDPOINT); + const rotated = await inboundWebhookSecretService.rotateSecret(ROTATION_ENDPOINT); + + const active = await inboundWebhookSecretService.getActiveSecrets(ROTATION_ENDPOINT); + expect(active).toEqual([rotated.secret]); + expect(active).not.toContain(original.secret); + }); + + it("rotateSecret with a grace period keeps both secrets valid until it elapses", async () => { + const original = await inboundWebhookSecretService.createSecret(ROTATION_ENDPOINT); + const rotated = await inboundWebhookSecretService.rotateSecret(ROTATION_ENDPOINT, { + graceSeconds: 3600, + }); + + const active = await inboundWebhookSecretService.getActiveSecrets(ROTATION_ENDPOINT); + expect(active.sort()).toEqual([original.secret, rotated.secret].sort()); + }); + + it("revokeSecret immediately invalidates a specific secret even mid-grace-period", async () => { + const original = await inboundWebhookSecretService.createSecret(ROTATION_ENDPOINT); + await inboundWebhookSecretService.rotateSecret(ROTATION_ENDPOINT, { graceSeconds: 3600 }); + await inboundWebhookSecretService.revokeSecret(ROTATION_ENDPOINT, original.id); + + const active = await inboundWebhookSecretService.getActiveSecrets(ROTATION_ENDPOINT); + expect(active).not.toContain(original.secret); + }); + + it("listSecrets reports metadata without ever exposing the plaintext or ciphertext", async () => { + await inboundWebhookSecretService.createSecret(ROTATION_ENDPOINT); + const list = await inboundWebhookSecretService.listSecrets(ROTATION_ENDPOINT); + + expect(list).toHaveLength(1); + expect(list[0]).not.toHaveProperty("secret"); + expect(list[0]).not.toHaveProperty("secretEncrypted"); + expect(list[0]).not.toHaveProperty("secretHash"); + expect(list[0].active).toBe(true); + }); +}); diff --git a/backend/migrations/027_inbound_webhook_secrets.js b/backend/migrations/027_inbound_webhook_secrets.js new file mode 100644 index 00000000..7cc3cad8 --- /dev/null +++ b/backend/migrations/027_inbound_webhook_secrets.js @@ -0,0 +1,38 @@ +/** + * Migration 027: Create inbound_webhook_secrets table. + * + * Per-endpoint shared secrets used by verifyInboundWebhookSignature.js to + * authenticate inbound webhook callbacks (e.g. POST /api/sep24/callback) + * via HMAC-SHA256. + * + * Mirrors the encrypted-at-rest pattern already used for outbound webhook + * secrets (see webhookService.js): the secret is stored as AES-256-GCM + * ciphertext (secret_encrypted) so it can be decrypted to compute the + * expected HMAC at verification time, plus a keyed HMAC-SHA256 fingerprint + * (secret_hash) for defense-in-depth / auditing without decryption. Raw + * secrets are never written to disk. + * + * Multiple rows can be `active` for the same endpoint at once — this is + * what makes rotation with a grace period possible: rotateSecret() inserts + * the new secret and, when a grace period is requested, leaves the old row + * active with `expires_at` set instead of deactivating it immediately. + */ + +exports.up = function (knex) { + return knex.schema.createTable("inbound_webhook_secrets", (table) => { + table.string("id").primary(); + table.string("endpoint").notNullable().comment("e.g. 'sep24_callback'"); + table.text("secret_encrypted").notNullable().comment("AES-256-GCM ciphertext — never plaintext"); + table.string("secret_hash").notNullable().comment("keyed HMAC-SHA256 fingerprint"); + table.boolean("active").notNullable().defaultTo(true); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("rotated_at").nullable().comment("set when superseded by a newer secret"); + table.timestamp("expires_at").nullable().comment("grace-period cutoff during rotation; null = no expiry while active"); + table.index("endpoint"); + table.index(["endpoint", "active"]); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists("inbound_webhook_secrets"); +}; diff --git a/backend/src/middleware/bodyParsing.js b/backend/src/middleware/bodyParsing.js index 1a93941a..f907572b 100644 --- a/backend/src/middleware/bodyParsing.js +++ b/backend/src/middleware/bodyParsing.js @@ -39,13 +39,41 @@ function requireJsonContentType(req, res, next) { next(); } +/** + * express.json()'s `verify` callback — stashes the exact bytes received on + * `req.rawBody` before JSON parsing. Inbound webhook signature verification + * (verifyInboundWebhookSignature.js) needs the raw bytes: re-serializing + * `req.body` with JSON.stringify() is not guaranteed to reproduce the exact + * string the sender signed (key order, whitespace, unicode escaping can all + * differ), which would make a correctly-signed request fail verification. + */ +function rawBodySaver(req, _res, buf) { + req.rawBody = buf; +} + +/** + * Build a JSON body parser with raw-body capture enabled. Exported so test + * apps can mount the exact same parser used in production (see + * bodyParsing(app) below) rather than a plain express.json() that would + * leave req.rawBody undefined. + */ +function jsonBodyParser(options = {}) { + return express.json({ limit: BODY_LIMIT_JSON, verify: rawBodySaver, ...options }); +} + /** * Apply body parsing middleware with configurable size limits. * Mount this on the Express app before routes. */ function bodyParsing(app) { - app.use(express.json({ limit: BODY_LIMIT_JSON })); + app.use(jsonBodyParser()); app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT_URLENCODED })); } -module.exports = { requireJsonContentType, bodyParsing, BODY_LIMIT_JSON, BODY_LIMIT_URLENCODED }; +module.exports = { + requireJsonContentType, + bodyParsing, + jsonBodyParser, + BODY_LIMIT_JSON, + BODY_LIMIT_URLENCODED, +}; diff --git a/backend/src/middleware/verifyInboundWebhookSignature.js b/backend/src/middleware/verifyInboundWebhookSignature.js new file mode 100644 index 00000000..0e87575d --- /dev/null +++ b/backend/src/middleware/verifyInboundWebhookSignature.js @@ -0,0 +1,141 @@ +/** + * src/middleware/verifyInboundWebhookSignature.js + * + * Authenticates INBOUND webhook callbacks (e.g. an anchor/payment processor + * POSTing transaction status updates to us) using a per-endpoint shared + * secret. Without this, any unauthenticated third party could forge a + * "payment completed" callback and trick the system into marking a + * transaction paid — a direct financial-integrity vulnerability. + * + * Required headers: + * X-Signature — hex-encoded HMAC-SHA256 of the raw request body, keyed + * by the endpoint's shared secret. + * X-Timestamp — Unix timestamp in seconds, when the request was signed. + * + * Verification, in order: + * 1. Both headers present. + * 2. X-Timestamp is a well-formed integer within ±WEBHOOK_REPLAY_WINDOW_SECONDS + * of the current time (default 300s / 5 min) — rejects stale requests. + * 3. X-Signature matches HMAC-SHA256(secret, rawBody) for at least one of + * the endpoint's currently-active secrets (crypto.timingSafeEqual — + * never `===` on HMACs), checked only after the timestamp is fresh. + * 4. This exact signature has not been seen before, within a window twice + * as long as the replay window — rejects replays of a previously-valid + * request. This check runs last (after the signature is confirmed + * valid) so an attacker without the secret can't use it to pollute the + * nonce store with garbage. + * + * Requires `req.rawBody` (a Buffer of the exact bytes received) — see + * bodyParsing.js's jsonBodyParser(), which must run before this middleware. + * Re-serializing req.body with JSON.stringify() is NOT used: it isn't + * guaranteed to reproduce byte-for-byte what the sender actually signed. + * + * Multiple endpoints can each have their own secret(s) — call this factory + * once per endpoint identifier, e.g.: + * router.post("/callback", verifyInboundWebhookSignature("sep24_callback"), handler) + */ + +"use strict"; + +const crypto = require("crypto"); +const { formatErrorResponse } = require("../../../shared/errorCodes"); +const inboundWebhookSecretService = require("../services/inboundWebhookSecretService"); +const cacheService = require("../services/cacheService"); +const logger = require("../utils/logger"); + +const REPLAY_WINDOW_SECONDS = parseInt(process.env.WEBHOOK_REPLAY_WINDOW_SECONDS || "300", 10); +const NONCE_TTL_SECONDS = REPLAY_WINDOW_SECONDS * 2; + +function reject(res, endpoint, reason) { + // Deliberately generic on the wire — do not tell the caller *which* check + // failed (missing vs. stale vs. bad signature vs. replay). The `reason` + // is only for our own logs. + logger.warn({ endpoint, reason }, "Inbound webhook signature rejected"); + return res + .status(401) + .json(formatErrorResponse("AUTH_INVALID_WEBHOOK_SIGNATURE")); +} + +/** + * @param {string} endpoint Identifier for the secret to verify against + * (e.g. "sep24_callback"). Passed to + * inboundWebhookSecretService.getActiveSecrets(). + * @returns {import('express').RequestHandler} + */ +function verifyInboundWebhookSignature(endpoint) { + return async function (req, res, next) { + const signature = req.headers["x-signature"]; + const timestampHeader = req.headers["x-timestamp"]; + + if (!signature || !timestampHeader) { + return reject(res, endpoint, "missing_headers"); + } + + const timestamp = Number(timestampHeader); + if (!Number.isFinite(timestamp)) { + return reject(res, endpoint, "malformed_timestamp"); + } + + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - timestamp) > REPLAY_WINDOW_SECONDS) { + return reject(res, endpoint, "stale_timestamp"); + } + + if (!req.rawBody) { + // Misconfiguration: jsonBodyParser()'s verify callback didn't run + // before this middleware. Fail closed, never open, on our own bug. + logger.error({ endpoint }, "verifyInboundWebhookSignature: req.rawBody is missing"); + return reject(res, endpoint, "missing_raw_body"); + } + + let signatureBuffer; + try { + signatureBuffer = Buffer.from(String(signature), "hex"); + } catch { + return reject(res, endpoint, "malformed_signature"); + } + + let activeSecrets; + try { + activeSecrets = await inboundWebhookSecretService.getActiveSecrets(endpoint); + } catch (err) { + logger.error({ endpoint, err: err.message }, "Failed to load inbound webhook secrets"); + return reject(res, endpoint, "secret_lookup_failed"); + } + + if (activeSecrets.length === 0) { + return reject(res, endpoint, "no_active_secret"); + } + + const isValidSignature = activeSecrets.some((secret) => { + const expected = crypto.createHmac("sha256", secret).update(req.rawBody).digest(); + return ( + expected.length === signatureBuffer.length && + crypto.timingSafeEqual(expected, signatureBuffer) + ); + }); + + if (!isValidSignature) { + return reject(res, endpoint, "signature_mismatch"); + } + + const nonceKey = `webhook:replay:${endpoint}:${signature}`; + let isFirstUse; + try { + isFirstUse = await cacheService.setIfNotExists(nonceKey, NONCE_TTL_SECONDS); + } catch (err) { + // Fail closed: if we can't confirm this signature is unused, treat it + // as a possible replay rather than letting it through. + logger.error({ endpoint, err: err.message }, "Replay-nonce check failed"); + return reject(res, endpoint, "replay_check_failed"); + } + + if (!isFirstUse) { + return reject(res, endpoint, "replay_detected"); + } + + next(); + }; +} + +module.exports = { verifyInboundWebhookSignature, REPLAY_WINDOW_SECONDS }; diff --git a/backend/src/routes/sep24.js b/backend/src/routes/sep24.js index 742bec95..a1f6e871 100644 --- a/backend/src/routes/sep24.js +++ b/backend/src/routes/sep24.js @@ -23,6 +23,9 @@ const { sep24DepositWithdrawSchema, } = require("../validation/schemas"); const { formatErrorResponse, ERROR_CODES } = require("../../../shared/errorCodes"); +const { verifyInboundWebhookSignature } = require("../middleware/verifyInboundWebhookSignature"); + +const SEP24_CALLBACK_ENDPOINT = "sep24_callback"; /** * POST /api/sep24/transactions/deposit/interactive @@ -186,8 +189,12 @@ router.get("/transactions/:txId", (req, res) => { /** * POST /api/sep24/callback * Webhook endpoint for the anchor to POST transaction status updates. + * + * Requires a valid X-Signature / X-Timestamp pair (see + * verifyInboundWebhookSignature.js) — an unauthenticated caller must not be + * able to flip a transaction to "completed". */ -router.post("/callback", (req, res) => { +router.post("/callback", verifyInboundWebhookSignature(SEP24_CALLBACK_ENDPOINT), (req, res) => { try { sep24Service.handleAnchorCallback(req.body); res.status(200).json({ received: true }); diff --git a/backend/src/server.js b/backend/src/server.js index cc4744bb..942d2a88 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -40,6 +40,7 @@ const turretsRoutes = require("./routes/turrets"); const tipsRoutes = require("./routes/tips"); const webhookRoutes = require("./routes/webhooks"); const { restoreWebhooks } = require("./services/webhookService"); +const inboundWebhookSecretService = require("./services/inboundWebhookSecretService"); const parsePaymentRoutes = require("./routes/parsePayment"); const scheduledTransactionRoutes = require("./routes/scheduledTransactions"); const sep24Routes = require("./routes/sep24"); @@ -525,6 +526,21 @@ if (require.main === module) { // streams. Must run after the server is bound so the port is guaranteed // ready before any incoming payment events trigger deliveries. await restoreWebhooks(); + // First boot only: provision a secret for the SEP-24 anchor callback + // if one doesn't already exist, so the endpoint is verifiable without + // a separate manual setup step. Printed once — never persisted to + // logs again — so an operator can capture it and configure it on the + // anchor's side. Use inboundWebhookSecretService.rotateSecret() to + // change it afterwards. + const newSep24Secret = await inboundWebhookSecretService.ensureSecretExists( + "sep24_callback", + ); + if (newSep24Secret) { + logger.warn( + { endpoint: "sep24_callback", secretId: newSep24Secret.id }, + `Generated a new sep24_callback webhook secret — configure this on the anchor's side, it will not be shown again: ${newSep24Secret.secret}`, + ); + } startTurretsServer(); eventIndexer.start(); startRetryWorker(); diff --git a/backend/src/services/cacheService.js b/backend/src/services/cacheService.js index 0bac7ec5..9f350c2a 100644 --- a/backend/src/services/cacheService.js +++ b/backend/src/services/cacheService.js @@ -391,6 +391,61 @@ function clearLRU() { lruCache.clear(); } +/** + * Atomically record that `key` has been seen, for once-only semantics + * (e.g. webhook replay-nonce detection). + * + * Returns `true` the first time a given key is set (i.e. this call "won" + * the race and the caller should proceed), `false` if the key was already + * present (a duplicate/replay). Unlike get()+set(), this is a single atomic + * operation — safe under concurrent calls with the same key. + * + * Redis path uses `SET key 1 EX ttl NX`, which is atomic server-side. The + * LRU fallback is a single synchronous check-then-insert, which is + * inherently atomic within one Node.js process (no other JS can run between + * the check and the insert). + * + * @param {string} key + * @param {number} ttlSeconds + * @returns {Promise} true if newly set, false if it already existed + */ +async function setIfNotExists(key, ttlSeconds) { + const span = tracer.startSpan("db.query.setIfNotExists"); + span.setAttributes({ + "db.system": "redis", + "db.operation": "setIfNotExists", + "db.statement": `SET ${key} EX ${ttlSeconds} NX`, + }); + + try { + if (redis && redisReady) { + try { + const result = await redis.set(key, "1", "EX", ttlSeconds, "NX"); + span.setAttribute("db.cache.newly_set", result === "OK"); + return result === "OK"; + } catch (err) { + logger.warn({ err, key }, "Redis setIfNotExists failed — falling back to LRU"); + } + } + + // LRU fallback: lruGet() already evicts expired entries, so a null + // result here means either never-seen or expired — both count as "new". + if (lruGet(key) !== null) { + span.setAttribute("db.cache.newly_set", false); + return false; + } + lruSet(key, "1", ttlSeconds); + span.setAttribute("db.cache.newly_set", true); + return true; + } catch (err) { + span.recordException(err); + span.setStatus({ code: 2, message: err.message }); + throw err; + } finally { + span.end(); + } +} + module.exports = { initRedis, closeRedis, @@ -401,4 +456,5 @@ module.exports = { del, delPattern, clearLRU, + setIfNotExists, }; diff --git a/backend/src/services/inboundWebhookSecretService.js b/backend/src/services/inboundWebhookSecretService.js new file mode 100644 index 00000000..f3a0b353 --- /dev/null +++ b/backend/src/services/inboundWebhookSecretService.js @@ -0,0 +1,176 @@ +/** + * src/services/inboundWebhookSecretService.js + * + * Per-endpoint shared secrets for verifying INBOUND webhook callbacks (e.g. + * anchors/payment processors calling POST /api/sep24/callback) — the + * counterpart to webhookService.js, which signs and delivers OUTBOUND + * webhooks. + * + * Storage follows the same pattern already used for outbound webhook + * secrets: AES-256-GCM ciphertext at rest (encryptSecret/decryptSecret), + * plus a keyed HMAC-SHA256 fingerprint (hashSecret, shared with + * webhookService.js via utils/webhookSecretHash.js) for defense-in-depth. + * The raw secret is only ever held in memory — once, at creation/rotation + * time, so it can be returned to the caller to configure on the + * counterparty's side. It is never logged. + * + * Rotation: multiple rows may be `active` for the same endpoint at once. + * rotateSecret() inserts a new secret and, when a grace period is given, + * keeps the old row active with `expires_at` set instead of deactivating it + * immediately — so an anchor that hasn't yet picked up the new secret keeps + * working until the grace period elapses. + */ + +"use strict"; + +const crypto = require("crypto"); +const knex = require("../db/connection"); +const { encryptSecret, decryptSecret } = require("../utils/encryption"); +const { hashSecret } = require("../utils/webhookSecretHash"); + +const TABLE = "inbound_webhook_secrets"; + +function generateId() { + return crypto.randomUUID(); +} + +/** Generate a new random secret (32 bytes, hex-encoded — 64 chars). */ +function generateSecretValue() { + return crypto.randomBytes(32).toString("hex"); +} + +function rowToMetadata(row) { + return { + id: row.id, + endpoint: row.endpoint, + active: !!row.active, + createdAt: row.created_at, + rotatedAt: row.rotated_at, + expiresAt: row.expires_at, + }; +} + +/** + * Create the first secret for an endpoint that doesn't have an active one + * yet. No-op (returns null) if an active secret already exists — safe to + * call unconditionally at startup. + * + * @param {string} endpoint + * @returns {Promise<{id: string, endpoint: string, secret: string}|null>} + * The plaintext secret is returned once, at creation time, only. + */ +async function ensureSecretExists(endpoint) { + const existing = await knex(TABLE).where({ endpoint, active: true }).first(); + if (existing) return null; + return createSecret(endpoint); +} + +/** + * Create a new active secret for `endpoint`. Does not deactivate any + * existing secrets — use rotateSecret() when replacing one. + * + * @param {string} endpoint + * @returns {Promise<{id: string, endpoint: string, secret: string, createdAt: string}>} + */ +async function createSecret(endpoint) { + const id = generateId(); + const secret = generateSecretValue(); + const createdAt = new Date().toISOString(); + + await knex(TABLE).insert({ + id, + endpoint, + secret_encrypted: encryptSecret(secret), + secret_hash: hashSecret(endpoint, secret), + active: true, + created_at: createdAt, + }); + + return { id, endpoint, secret, createdAt }; +} + +/** + * Rotate the secret for `endpoint`: create a new active secret, and either + * immediately deactivate the previously-active secret(s) (graceSeconds=0, + * the default) or give them a bounded grace window during which both the + * old and new secret verify successfully. + * + * @param {string} endpoint + * @param {{graceSeconds?: number}} [options] + * @returns {Promise<{id: string, endpoint: string, secret: string, createdAt: string}>} + */ +async function rotateSecret(endpoint, { graceSeconds = 0 } = {}) { + const now = new Date(); + const previouslyActive = await knex(TABLE).where({ endpoint, active: true }); + + const created = await createSecret(endpoint); + + if (previouslyActive.length > 0) { + const ids = previouslyActive.map((row) => row.id); + if (graceSeconds > 0) { + const expiresAt = new Date(now.getTime() + graceSeconds * 1000).toISOString(); + await knex(TABLE) + .whereIn("id", ids) + .update({ rotated_at: now.toISOString(), expires_at: expiresAt }); + } else { + await knex(TABLE) + .whereIn("id", ids) + .update({ active: false, rotated_at: now.toISOString() }); + } + } + + return created; +} + +/** + * Immediately revoke a specific secret (e.g. suspected leak), regardless of + * any grace period a rotation may have granted it. + * + * @param {string} endpoint + * @param {string} id + * @returns {Promise} true if a row was revoked + */ +async function revokeSecret(endpoint, id) { + const count = await knex(TABLE) + .where({ endpoint, id }) + .update({ active: false, expires_at: new Date().toISOString() }); + return count > 0; +} + +/** + * Return every currently-valid (active and, if `expires_at` is set, not yet + * expired) plaintext secret for `endpoint`, decrypted. Used internally by + * verifyInboundWebhookSignature.js — never returned over HTTP. + * + * @param {string} endpoint + * @returns {Promise} + */ +async function getActiveSecrets(endpoint) { + const nowIso = new Date().toISOString(); + const rows = await knex(TABLE) + .where({ endpoint, active: true }) + .where((builder) => builder.whereNull("expires_at").orWhere("expires_at", ">", nowIso)); + + return rows.map((row) => decryptSecret(row.secret_encrypted)); +} + +/** + * List secret metadata (no plaintext, no ciphertext) for an endpoint — + * for operational visibility (e.g. "which secrets are currently active"). + * + * @param {string} endpoint + * @returns {Promise} + */ +async function listSecrets(endpoint) { + const rows = await knex(TABLE).where({ endpoint }).orderBy("created_at", "desc"); + return rows.map(rowToMetadata); +} + +module.exports = { + ensureSecretExists, + createSecret, + rotateSecret, + revokeSecret, + getActiveSecrets, + listSecrets, +}; diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js index 4ff27495..148f9a06 100644 --- a/backend/src/services/webhookService.js +++ b/backend/src/services/webhookService.js @@ -49,6 +49,7 @@ const { propagation, context } = require("@opentelemetry/api"); const { getRequestIdHeader } = require("../utils/correlationId"); const { generateWebhookSignature } = require("../utils/webhookSignature"); const { encryptSecret, decryptSecret } = require("../utils/encryption"); +const { hashSecret, WEBHOOK_SECRET_KEY } = require("../utils/webhookSecretHash"); const knex = require("../db/connection"); require("dotenv").config(); @@ -69,21 +70,6 @@ const MAX_RETRIES = parseInt(process.env.WEBHOOK_MAX_RETRIES, 10) || 6; const RETRY_INTERVALS_SECONDS = [60, 300, 900, 3600, 21600, 86400]; const RETRY_WORKER_INTERVAL = 30000; -/** - * Server-side secret used to produce the stored HMAC-SHA256 hash. - * Must be set in the environment; defaults to a generated value that won't - * survive restarts — force explicit configuration in production. - */ -const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || crypto.randomBytes(32).toString("hex"); - -if (!process.env.WEBHOOK_SECRET_KEY && process.env.NODE_ENV !== "test") { - logger.warn( - "WEBHOOK_SECRET_KEY is not set — a random key will be used. " + - "Stored secret hashes will not be reproducible across restarts. " + - "Set WEBHOOK_SECRET_KEY in your environment for production use.", - ); -} - /** In-process cache of the most recently registered webhooks (by id). The DB * is the source of truth — this Map just gives the SSE delivery path a * cheap way to resolve `id → secret + url` without a SELECT per payment. */ @@ -98,21 +84,6 @@ const pendingDeliveries = new Set(); let retryWorkerTimer = null; -// ─── Secret hashing ─────────────────────────────────────────────────────────── - -/** - * Produce a deterministic HMAC-SHA256 hash of `secret` keyed by `id`. - * This is stored alongside the encrypted secret so the hash can be - * verified without decryption. - * - * @param {string} id - * @param {string} secret - * @returns {string} hex digest - */ -function hashSecret(id, secret) { - return crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(`${id}:${secret}`).digest("hex"); -} - // ─── ID generation ──────────────────────────────────────────────────────────── /** @@ -802,4 +773,10 @@ module.exports = { restoreWebhooks, MAX_RETRIES, RETRY_INTERVALS_SECONDS, + // Re-exported for backward compatibility — these now live in + // utils/webhookSecretHash.js (shared with inboundWebhookSecretService.js) + // so that dependency-free module can be required without pulling in this + // file's @stellar/stellar-sdk dependency. + hashSecret, + WEBHOOK_SECRET_KEY, }; diff --git a/backend/src/utils/webhookSecretHash.js b/backend/src/utils/webhookSecretHash.js new file mode 100644 index 00000000..aebfb5bc --- /dev/null +++ b/backend/src/utils/webhookSecretHash.js @@ -0,0 +1,49 @@ +/** + * src/utils/webhookSecretHash.js + * + * Keyed HMAC-SHA256 fingerprint for webhook secrets — stored alongside the + * AES-256-GCM ciphertext so a secret can be verified/audited without + * decryption. Shared by webhookService.js (outbound webhook secrets) and + * inboundWebhookSecretService.js (inbound webhook secrets). + * + * Deliberately dependency-free beyond `crypto`: webhookService.js pulls in + * @stellar/stellar-sdk for Horizon SSE streaming, which has nothing to do + * with hashing a secret — importing it just for hashSecret() would drag in + * that entire (and, in this repo's Jest setup, ESM-transform-troublesome) + * dependency chain for no reason. + */ + +"use strict"; + +const crypto = require("crypto"); +const logger = require("./logger"); + +/** + * Server-side secret used to produce the stored HMAC-SHA256 hash. + * Must be set in the environment; defaults to a generated value that won't + * survive restarts — force explicit configuration in production. + */ +const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || crypto.randomBytes(32).toString("hex"); + +if (!process.env.WEBHOOK_SECRET_KEY && process.env.NODE_ENV !== "test") { + logger.warn( + "WEBHOOK_SECRET_KEY is not set — a random key will be used. " + + "Stored secret hashes will not be reproducible across restarts. " + + "Set WEBHOOK_SECRET_KEY in your environment for production use.", + ); +} + +/** + * Produce a deterministic HMAC-SHA256 hash of `secret` keyed by `id`. + * This is stored alongside the encrypted secret so the hash can be + * verified without decryption. + * + * @param {string} id + * @param {string} secret + * @returns {string} hex digest + */ +function hashSecret(id, secret) { + return crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(`${id}:${secret}`).digest("hex"); +} + +module.exports = { hashSecret, WEBHOOK_SECRET_KEY }; diff --git a/shared/errorCodes.js b/shared/errorCodes.js index fb8d8fff..faf47ca3 100644 --- a/shared/errorCodes.js +++ b/shared/errorCodes.js @@ -98,6 +98,11 @@ const ERROR_CODES = { httpStatus: 401, message: "SEP-0010 challenge verification failed.", }, + AUTH_INVALID_WEBHOOK_SIGNATURE: { + code: "AUTH_INVALID_WEBHOOK_SIGNATURE", + httpStatus: 401, + message: "Webhook signature verification failed.", + }, /** * Deprecated alias for AUTH_EXPIRED_TOKEN. Shipped before this catalogue * existed and is asserted by existing consumers, so the API still emits it