From 3e8b64b3b2549f2ce92db931ade97c5e23464b7d Mon Sep 17 00:00:00 2001 From: isavaima8-alt Date: Sun, 27 Sep 2026 16:05:22 +0000 Subject: [PATCH] feat: secure webhook replay and privileged API writes --- README.md | 18 ++- comebackhere-backend/src/app.ts | 4 + comebackhere-backend/src/db/mongo.ts | 39 ++++++ .../src/middleware/adminAuth.ts | 28 ++++ comebackhere-backend/src/middleware/apiKey.ts | 25 ++++ comebackhere-backend/src/routes/api-keys.ts | 96 +++++++++++++ comebackhere-backend/src/routes/compliance.ts | 20 +-- .../src/routes/invoice-settings.ts | 3 +- comebackhere-backend/src/routes/invoices.ts | 24 ++-- .../src/routes/release-escrow.ts | 11 +- comebackhere-backend/src/routes/threshold.ts | 3 +- comebackhere-backend/src/routes/treasury.ts | 9 +- comebackhere-backend/src/routes/webhooks.ts | 67 +++++++++ comebackhere-backend/src/schemas/index.ts | 8 +- .../src/services/treasury-indexer.ts | 8 +- comebackhere-backend/src/services/webhooks.ts | 128 +++++++++++++++--- docs/api-reference.md | 54 +++++++- docs/webhooks.md | 63 ++++++--- 18 files changed, 517 insertions(+), 91 deletions(-) create mode 100644 comebackhere-backend/src/middleware/adminAuth.ts create mode 100644 comebackhere-backend/src/middleware/apiKey.ts create mode 100644 comebackhere-backend/src/routes/api-keys.ts create mode 100644 comebackhere-backend/src/routes/webhooks.ts diff --git a/README.md b/README.md index 212d94e..155de61 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ docs rather than duplicating them. ```bash curl -X POST http://localhost:3000/invoices \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $MERCHANT_API_KEY" \ -d '{ - "merchant_address": "G...", "token": "USDC", "amount": 1000000, "due_date": 1720000000 @@ -49,17 +49,25 @@ Configure `WEBHOOK_URL` and `WEBHOOK_SIGNING_SECRET` in your environment so the backend can notify your system when payments land. All outbound webhook POSTs are signed with HMAC-SHA256. Verify the -`X-COMEBACKHERE-Signature` header before processing: +`X-COMEBACKHERE-Signature` and `X-COMEBACKHERE-Timestamp` headers before +processing. Reject timestamps more than five minutes from your current time: ```typescript import { createHmac, timingSafeEqual } from "crypto" -function verifyWebhook(rawBody: string, signature: string, secret: string): boolean { - const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex") - return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex")) +function verifyWebhook(rawBody: string, signature: string, timestamp: string, secret: string): boolean { + const seconds = Number(timestamp) + if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false + const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex") + const expectedBytes = Buffer.from(expected, "hex") + const actualBytes = Buffer.from(signature, "hex") + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes) } ``` +The body-only digest is temporarily available as `X-COMEBACKHERE-Legacy-Signature` +for one release while receivers migrate. + > Webhook events and configuration: [docs/api-reference.md](docs/api-reference.md#webhooks) ### 3. Minimal payment flow diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 71fb22a..5dc4436 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -9,6 +9,8 @@ import invoiceSettingsRouter from "./routes/invoice-settings.js" import thresholdRouter from "./routes/threshold.js" import disputesRouter from "./routes/disputes.js" import analyticsRouter from "./routes/analytics.js" +import apiKeysRouter from "./routes/api-keys.js" +import webhooksRouter from "./routes/webhooks.js" import { startComplianceIndexer } from "./services/compliance-indexer.js" import { rateLimitMiddleware } from "./middleware/rateLimiter.js" import { correlationIdMiddleware } from "./middleware/correlationId.js" @@ -103,6 +105,8 @@ export function createApp(options: CreateAppOptions = {}) { app.use("/api/treasury", thresholdRouter) app.use("/disputes", disputesRouter) app.use("/api/analytics", analyticsRouter) + app.use("/api/merchant-keys", apiKeysRouter) + app.use("/webhooks", webhooksRouter) // ── Errors ────────────────────────────────────────────────────────────────── // Everything below produces { error: { code, message, details, correlationId } } diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 4126742..fb5f251 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -25,6 +25,36 @@ export interface InvoiceRecord { updated_at: Date } +export interface MerchantApiKeyRecord { + key_id: string + merchant_address: string + key_hash: string + created_at: Date + revoked_at?: Date +} + +export interface WebhookReplayRecord { + replay_id: string + request_id: string | null + attempted_at: Date + status: "delivered" | "failed" + status_code: number | null + error: string | null +} + +export interface WebhookDeliveryHistoryRecord { + delivery_id: string + merchant_address: string + endpoint: string + payload: unknown + status: "delivered" | "failed" + attempts: number + last_status_code: number | null + last_error: string | null + created_at: Date + replays: WebhookReplayRecord[] +} + export interface SettlementRecord { id: number merchant_address: string @@ -190,6 +220,15 @@ export async function connectMongo(): Promise { await invoiceEvents.createIndex({ event_id: 1 }, { unique: true }) await invoiceEvents.createIndex({ invoice_id: 1, ledger: 1 }) + const merchantApiKeys = db.collection("merchant_api_keys") + await merchantApiKeys.createIndex({ key_id: 1 }, { unique: true }) + await merchantApiKeys.createIndex({ key_hash: 1 }, { unique: true }) + await merchantApiKeys.createIndex({ merchant_address: 1, revoked_at: 1 }) + + const webhookDeliveries = db.collection("webhook_deliveries") + await webhookDeliveries.createIndex({ delivery_id: 1 }, { unique: true }) + await webhookDeliveries.createIndex({ merchant_address: 1, created_at: -1 }) + const complianceAudit = db.collection("compliance_audit") await complianceAudit.createIndex({ event_id: 1 }, { unique: true }) await complianceAudit.createIndex({ address: 1, ledger: -1 }) diff --git a/comebackhere-backend/src/middleware/adminAuth.ts b/comebackhere-backend/src/middleware/adminAuth.ts new file mode 100644 index 0000000..44a3a8d --- /dev/null +++ b/comebackhere-backend/src/middleware/adminAuth.ts @@ -0,0 +1,28 @@ +import { createHash } from "crypto" +import type { RequestHandler } from "express" +import { ForbiddenError, ServiceMisconfiguredError, UnauthorizedError } from "../lib/errors.js" + +export const requireAdmin: RequestHandler = (req, res, next) => { + const configuredKey = process.env.ADMIN_KEY + if (!configuredKey) { + next(new ServiceMisconfiguredError("ADMIN_KEY is not configured")) + return + } + + const suppliedKey = req.get("x-admin-key") + if (!suppliedKey) { + next(new UnauthorizedError("Admin credentials are required")) + return + } + if (suppliedKey !== configuredKey) { + next(new ForbiddenError("Admin credentials are invalid")) + return + } + + const keyFingerprint = createHash("sha256").update(configuredKey).digest("hex").slice(0, 12) + const adminIdentity = process.env.ADMIN_IDENTITY ?? `admin-${keyFingerprint}` + res.locals.adminIdentity = adminIdentity + const requestId = typeof res.locals.requestId === "string" ? res.locals.requestId : "-" + console.info(`[admin-audit] requestId=${requestId} admin=${adminIdentity} action=${req.method} ${req.path}`) + next() +} diff --git a/comebackhere-backend/src/middleware/apiKey.ts b/comebackhere-backend/src/middleware/apiKey.ts new file mode 100644 index 0000000..9762860 --- /dev/null +++ b/comebackhere-backend/src/middleware/apiKey.ts @@ -0,0 +1,25 @@ +import { createHash } from "crypto" +import type { RequestHandler } from "express" +import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js" +import { UnauthorizedError } from "../lib/errors.js" + +export function hashMerchantApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex") +} + +export const requireMerchantApiKey: RequestHandler = (req, res, next) => { + const authorization = req.get("authorization") + const match = authorization?.match(/^Bearer (\S+)$/i) + if (!match) { + next(new UnauthorizedError("A merchant API key is required")) + return + } + + void (async () => { + const keys = (await connectMongo()).collection("merchant_api_keys") + const record = await keys.findOne({ key_hash: hashMerchantApiKey(match[1]), revoked_at: { $exists: false } }) + if (!record) throw new UnauthorizedError("Merchant API key is invalid or revoked") + res.locals.merchantAddress = record.merchant_address + next() + })().catch(next) +} diff --git a/comebackhere-backend/src/routes/api-keys.ts b/comebackhere-backend/src/routes/api-keys.ts new file mode 100644 index 0000000..ec52855 --- /dev/null +++ b/comebackhere-backend/src/routes/api-keys.ts @@ -0,0 +1,96 @@ +import { randomBytes, randomUUID } from "crypto" +import { Router, type Request, type Response } from "express" +import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js" +import { asyncHandler, NotFoundError } from "../lib/errors.js" +import { requireAdmin } from "../middleware/adminAuth.js" +import { hashMerchantApiKey } from "../middleware/apiKey.js" +import { validateBody, validateParams } from "../middleware/validate.js" +import { merchantApiKeySchema, merchantApiKeyIdSchema } from "../schemas/index.js" + +const router = Router() + +/** + * @openapi + * /api/merchant-keys: + * post: + * tags: [Merchant Authentication] + * summary: Create or rotate a merchant API key + * parameters: + * - in: header + * name: X-Admin-Key + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [merchant_address] + * properties: + * merchant_address: { type: string, example: GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX } + * responses: + * 201: + * description: API key created; plaintext is returned only once + * 401: + * description: Admin credentials are missing + * 403: + * description: Admin credentials are invalid + */ +router.post("/", requireAdmin, validateBody(merchantApiKeySchema), asyncHandler(async (req: Request, res: Response) => { + const merchantAddress = req.body.merchant_address as string + const key = `ch_${randomBytes(32).toString("hex")}` + const keyId = randomUUID() + const createdAt = new Date() + const keys = (await connectMongo()).collection("merchant_api_keys") + + await keys.insertOne({ + key_id: keyId, + merchant_address: merchantAddress, + key_hash: hashMerchantApiKey(key), + created_at: createdAt, + }) + await keys.updateMany( + { merchant_address: merchantAddress, key_id: { $ne: keyId }, revoked_at: { $exists: false } }, + { $set: { revoked_at: createdAt } }, + ) + + res.status(201).json({ key_id: keyId, merchant_address: merchantAddress, api_key: key, created_at: createdAt }) +})) + +/** + * @openapi + * /api/merchant-keys/{keyId}: + * delete: + * tags: [Merchant Authentication] + * summary: Revoke a merchant API key + * parameters: + * - in: path + * name: keyId + * required: true + * schema: { type: string, format: uuid } + * - in: header + * name: X-Admin-Key + * required: true + * schema: { type: string } + * responses: + * 204: + * description: API key revoked + * 401: + * description: Admin credentials are missing + * 403: + * description: Admin credentials are invalid + * 404: + * description: Active key not found + */ +router.delete("/:keyId", requireAdmin, validateParams(merchantApiKeyIdSchema), asyncHandler(async (req: Request, res: Response) => { + const keys = (await connectMongo()).collection("merchant_api_keys") + const result = await keys.updateOne( + { key_id: req.params.keyId, revoked_at: { $exists: false } }, + { $set: { revoked_at: new Date() } }, + ) + if (result.matchedCount === 0) throw new NotFoundError("Active merchant API key not found") + res.status(204).end() +})) + +export default router diff --git a/comebackhere-backend/src/routes/compliance.ts b/comebackhere-backend/src/routes/compliance.ts index b4e8496..b5a7adc 100644 --- a/comebackhere-backend/src/routes/compliance.ts +++ b/comebackhere-backend/src/routes/compliance.ts @@ -9,7 +9,8 @@ import { } from "stellar-sdk" import { validateBody, validateQuery } from "../middleware/validate.js" import { requireEnv } from "../lib/env.js" -import { asyncHandler, UnauthorizedError } from "../lib/errors.js" +import { asyncHandler } from "../lib/errors.js" +import { requireAdmin } from "../middleware/adminAuth.js" import { allowBodySchema, blockBodySchema, complianceAuditQuerySchema } from "../schemas/index.js" import { connectMongo, getComplianceAuditCollection } from "../db/mongo.js" @@ -178,12 +179,7 @@ export interface AllowBody { * Body: { address: string, until?: number } * Returns: { address, status, hash } */ -router.post("/allow", validateBody(allowBodySchema), asyncHandler(async (req: Request, res: Response) => { - const adminKey = req.headers["x-admin-key"] - if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - throw new UnauthorizedError() - } - +router.post("/allow", requireAdmin, validateBody(allowBodySchema), asyncHandler(async (req: Request, res: Response) => { const { address, until } = req.body as { address: string; until?: number } const env = requireEnv({ @@ -222,12 +218,7 @@ export interface BlockBody { * Body: { address: string } * Returns: { address, status, hash } */ -router.post("/block", validateBody(blockBodySchema), asyncHandler(async (req: Request, res: Response) => { - const adminKey = req.headers["x-admin-key"] - if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - throw new UnauthorizedError() - } - +router.post("/block", requireAdmin, validateBody(blockBodySchema), asyncHandler(async (req: Request, res: Response) => { const { address } = req.body as { address: string } const env = requireEnv({ @@ -235,8 +226,7 @@ router.post("/block", validateBody(blockBodySchema), asyncHandler(async (req: Re signerSecret: "SIGNER_SECRET_KEY", }) - // Audit log — admin identity + timestamp - console.log(`[compliance] block_address admin="${adminKey}" address="${address}" ts="${new Date().toISOString()}"`) + // The shared middleware logs the admin identity and correlation ID. const client = buildSorobanClient(env.rpcUrl) const result = await callComplianceOp( diff --git a/comebackhere-backend/src/routes/invoice-settings.ts b/comebackhere-backend/src/routes/invoice-settings.ts index 04edee1..d5c5715 100644 --- a/comebackhere-backend/src/routes/invoice-settings.ts +++ b/comebackhere-backend/src/routes/invoice-settings.ts @@ -9,6 +9,7 @@ import { import { requireEnv } from "../lib/env.js" import { asyncHandler } from "../lib/errors.js" import { validateBody } from "../middleware/validate.js" +import { requireAdmin } from "../middleware/adminAuth.js" import { graceWindowSchema } from "../schemas/index.js" const router = Router() @@ -136,7 +137,7 @@ export async function setGraceWindow( * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/grace-window", validateBody(graceWindowSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/grace-window", requireAdmin, validateBody(graceWindowSchema), asyncHandler(async (req: Request, res: Response) => { const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index 0a9bc22..b0219f0 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -6,6 +6,7 @@ import { requireEnv } from "../lib/env.js" import { asyncHandler, NotFoundError } from "../lib/errors.js" import { cacheGet, cacheSet } from "../lib/cache.js" import { validateBody, validateParams } from "../middleware/validate.js" +import { requireMerchantApiKey } from "../middleware/apiKey.js" import { createInvoiceSchema, invoiceIdParamSchema } from "../schemas/index.js" const router = Router() @@ -488,18 +489,19 @@ router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req * post: * tags: [Invoices] * summary: Create a new invoice + * parameters: + * - in: header + * name: Authorization + * required: true + * schema: { type: string, example: Bearer merchant-api-key } * requestBody: * required: true * content: * application/json: * schema: * type: object - * required: [merchant_address, token, amount, due_date] + * required: [token, amount, due_date] * properties: - * merchant_address: - * type: string - * description: Valid Stellar public key (G…) - * example: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" * token: * type: string * example: "USDC" @@ -535,6 +537,12 @@ router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Missing, invalid, or revoked merchant API key + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' * 422: * description: Soroban simulation or transaction failure * content: @@ -554,15 +562,16 @@ router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/", validateBody(createInvoiceSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/", requireMerchantApiKey, validateBody(createInvoiceSchema), asyncHandler(async (req: Request, res: Response) => { const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) const client = buildSorobanClient(env.rpcUrl) + const body = { ...req.body, merchant_address: res.locals.merchantAddress } as CreateInvoiceBody const result = await createInvoice( - req.body as CreateInvoiceBody, + body, client, env.invoiceContractId, env.signerSecret, @@ -571,7 +580,6 @@ router.post("/", validateBody(createInvoiceSchema), asyncHandler(async (req: Req const db = await connectMongo() const collection = getInvoicesCollection(db) - const body = req.body as CreateInvoiceBody const now = new Date() await collection.insertOne({ invoice_id: result.invoice_id, diff --git a/comebackhere-backend/src/routes/release-escrow.ts b/comebackhere-backend/src/routes/release-escrow.ts index 1fef979..0c4e6e5 100644 --- a/comebackhere-backend/src/routes/release-escrow.ts +++ b/comebackhere-backend/src/routes/release-escrow.ts @@ -6,8 +6,9 @@ import { type SorobanClient, } from "../lib/soroban.js" import { requireEnv } from "../lib/env.js" -import { asyncHandler, ContractError, parseContractErrorCode, UnauthorizedError } from "../lib/errors.js" +import { asyncHandler, ContractError, parseContractErrorCode } from "../lib/errors.js" import { validateBody, validateParams } from "../middleware/validate.js" +import { requireAdmin } from "../middleware/adminAuth.js" import { releaseEscrowIdParamSchema } from "../schemas/index.js" const router = Router({ mergeParams: true }) @@ -71,13 +72,7 @@ export async function releaseEscrow( * 503 required environment variables missing * 5xx unexpected Soroban / network error */ -router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), asyncHandler(async (req: Request, res: Response) => { - // Admin-only authorization - const adminKey = req.headers["x-admin-key"] - if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - throw new UnauthorizedError() - } - +router.post("/:id/release-escrow", requireAdmin, validateParams(releaseEscrowIdParamSchema), asyncHandler(async (req: Request, res: Response) => { const { id } = req.params const invoiceId = parseInt(id, 10) diff --git a/comebackhere-backend/src/routes/threshold.ts b/comebackhere-backend/src/routes/threshold.ts index c408ef2..671b1c7 100644 --- a/comebackhere-backend/src/routes/threshold.ts +++ b/comebackhere-backend/src/routes/threshold.ts @@ -9,6 +9,7 @@ import { import { requireEnv } from "../lib/env.js" import { asyncHandler } from "../lib/errors.js" import { validateBody } from "../middleware/validate.js" +import { requireAdmin } from "../middleware/adminAuth.js" import { thresholdSchema } from "../schemas/index.js" const router = Router() @@ -70,7 +71,7 @@ export async function setThreshold( return { threshold, tx_hash: txHash } } -router.post("/threshold", validateBody(thresholdSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/threshold", requireAdmin, validateBody(thresholdSchema), asyncHandler(async (req: Request, res: Response) => { const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index a7091df..85753a4 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -12,6 +12,7 @@ import { requireEnv } from "../lib/env.js" import { asyncHandler, NotFoundError } from "../lib/errors.js" import { connectMongo, getSettlementsCollection } from "../db/mongo.js" import { validateBody } from "../middleware/validate.js" +import { requireAdmin } from "../middleware/adminAuth.js" import { settlementIdSchema, executeSettlementSchema, @@ -106,7 +107,7 @@ router.get("/pending-settlements", asyncHandler(async (_req: Request, res: Respo * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/approve-settlement", validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/approve-settlement", requireAdmin, validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", usdcContractId: "USDC_CONTRACT_ID", @@ -313,7 +314,7 @@ export async function executeSettlementWithBalanceCheck( * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/execute-settlement", validateBody(executeSettlementSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/execute-settlement", requireAdmin, validateBody(executeSettlementSchema), asyncHandler(async (req: Request, res: Response) => { const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", usdcContractId: "USDC_CONTRACT_ID", @@ -562,7 +563,7 @@ router.get("/on-hold-settlements", asyncHandler(async (_req: Request, res: Respo * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/release-hold", validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/release-hold", requireAdmin, validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { const settlementId = req.body.settlement_id const database = await connectMongo() @@ -651,7 +652,7 @@ router.post("/release-hold", validateBody(settlementIdSchema), asyncHandler(asyn * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/escalate-hold", validateBody(escalateHoldSchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/escalate-hold", requireAdmin, validateBody(escalateHoldSchema), asyncHandler(async (req: Request, res: Response) => { const settlementId = req.body.settlement_id const database = await connectMongo() diff --git a/comebackhere-backend/src/routes/webhooks.ts b/comebackhere-backend/src/routes/webhooks.ts new file mode 100644 index 0000000..cad5257 --- /dev/null +++ b/comebackhere-backend/src/routes/webhooks.ts @@ -0,0 +1,67 @@ +import { Router, type Request, type Response } from "express" +import { connectMongo, type WebhookDeliveryHistoryRecord } from "../db/mongo.js" +import { asyncHandler, ConflictError, NotFoundError } from "../lib/errors.js" +import { requireMerchantApiKey } from "../middleware/apiKey.js" +import { validateParams } from "../middleware/validate.js" +import { webhookDeliveryIdSchema } from "../schemas/index.js" +import { dispatchWebhook, type WebhookPayload } from "../services/webhooks.js" + +const router = Router() + +/** + * @openapi + * /webhooks/{deliveryId}/replay: + * post: + * tags: [Webhooks] + * summary: Replay a failed webhook delivery + * parameters: + * - in: path + * name: deliveryId + * required: true + * schema: { type: string, format: uuid } + * - in: header + * name: Authorization + * required: true + * schema: { type: string, example: Bearer merchant-api-key } + * responses: + * 200: + * description: Replay delivered + * 401: + * description: Merchant API key is missing, invalid, or revoked + * 404: + * description: Delivery does not belong to this merchant + * 409: + * description: Only failed deliveries can be replayed + * 502: + * description: Merchant endpoint returned a non-success response + */ +router.post("/:deliveryId/replay", requireMerchantApiKey, validateParams(webhookDeliveryIdSchema), asyncHandler(async (req: Request, res: Response) => { + const merchantAddress = res.locals.merchantAddress as string + const collection = (await connectMongo()).collection("webhook_deliveries") + const delivery = await collection.findOne({ + delivery_id: req.params.deliveryId, + merchant_address: merchantAddress, + }) + if (!delivery) throw new NotFoundError("Webhook delivery not found") + if (delivery.status !== "failed") throw new ConflictError("Only failed webhook deliveries can be replayed") + + const result = await dispatchWebhook( + delivery.endpoint, + delivery.payload as WebhookPayload, + undefined, + fetch, + { + merchantAddress, + correlationId: typeof res.locals.requestId === "string" ? res.locals.requestId : undefined, + replayOf: delivery.delivery_id, + }, + ) + res.status(result.ok ? 200 : 502).json({ + delivery_id: delivery.delivery_id, + replay_id: result.deliveryId, + status: result.ok ? "delivered" : "failed", + status_code: result.status, + }) +})) + +export default router diff --git a/comebackhere-backend/src/schemas/index.ts b/comebackhere-backend/src/schemas/index.ts index 9ee5257..6c743f2 100644 --- a/comebackhere-backend/src/schemas/index.ts +++ b/comebackhere-backend/src/schemas/index.ts @@ -28,10 +28,6 @@ const futureTimestamp = z }) export const createInvoiceSchema = z.object({ - merchant_address: z - .string() - .min(1, "merchant_address is required") - .refine(isValidStellarAddress, "merchant_address must be a valid Stellar public key"), token: z.string().min(1, "token is required"), amount: z .number({ message: "amount must be a positive number" }) @@ -43,6 +39,10 @@ export const createInvoiceSchema = z.object({ .optional(), }) +export const merchantApiKeySchema = z.object({ merchant_address: stellarAddress }) +export const merchantApiKeyIdSchema = z.object({ keyId: z.string().uuid("keyId must be a UUID") }) +export const webhookDeliveryIdSchema = z.object({ deliveryId: z.string().uuid("deliveryId must be a UUID") }) + export const invoiceIdParamSchema = z.object({ id: z.string().regex(/^\d+$/, "id must be a positive integer"), }) diff --git a/comebackhere-backend/src/services/treasury-indexer.ts b/comebackhere-backend/src/services/treasury-indexer.ts index 76125f2..0ef5336 100644 --- a/comebackhere-backend/src/services/treasury-indexer.ts +++ b/comebackhere-backend/src/services/treasury-indexer.ts @@ -249,7 +249,7 @@ export async function processIndexerBatch( amount: amount.toString(), token, tx_hash: txHash, - }).catch((err: unknown) => { + }, undefined, fetch, { merchantAddress: merchant }).catch((err: unknown) => { console.error( "[treasury-indexer] webhook dispatch failed (settlement_proposed):", err instanceof Error ? err.message : err, @@ -260,6 +260,7 @@ export async function processIndexerBatch( const signer = valueAddress(event.value, 1) const newWeight = valueU64(event.value, 3) await processSettlementApproved(settlements, settlementId, signer, newWeight) + const settlement = await settlements.findOne({ id: settlementId }) // Dispatch signed webhook for settlement_approved const webhookUrl = process.env.WEBHOOK_URL @@ -270,7 +271,7 @@ export async function processIndexerBatch( signer, approval_weight: newWeight.toString(), tx_hash: txHash, - }).catch((err: unknown) => { + }, undefined, fetch, { merchantAddress: settlement?.merchant_address }).catch((err: unknown) => { console.error( "[treasury-indexer] webhook dispatch failed (settlement_approved):", err instanceof Error ? err.message : err, @@ -279,6 +280,7 @@ export async function processIndexerBatch( } } else if (eventType === "settlement_executed") { await processSettlementExecuted(settlements, settlementId, txHash) + const settlement = await settlements.findOne({ id: settlementId }) // Treasury balances changed on-chain; drop the cached copy so the next // GET /api/treasury/balances reads fresh data instead of waiting for TTL. @@ -291,7 +293,7 @@ export async function processIndexerBatch( event: "settlement_executed", settlement_id: settlementId, tx_hash: txHash, - }).catch((err: unknown) => { + }, undefined, fetch, { merchantAddress: settlement?.merchant_address }).catch((err: unknown) => { console.error( "[treasury-indexer] webhook dispatch failed (settlement_executed):", err instanceof Error ? err.message : err, diff --git a/comebackhere-backend/src/services/webhooks.ts b/comebackhere-backend/src/services/webhooks.ts index 509872b..131f57a 100644 --- a/comebackhere-backend/src/services/webhooks.ts +++ b/comebackhere-backend/src/services/webhooks.ts @@ -1,15 +1,15 @@ /** * Outbound webhook delivery with HMAC-SHA256 request signing. * - * Every webhook POST includes a `X-COMEBACKHERE-Signature` header containing - * an HMAC-SHA256 hex digest of the raw JSON request body, keyed by the - * per-merchant signing secret (`WEBHOOK_SIGNING_SECRET` env var, or the - * `signingSecret` argument when called directly). + * Every webhook POST includes a timestamp and an HMAC-SHA256 signature over + * `timestamp.rawBody`, plus a body-only legacy digest during the migration + * release. Both digests use `WEBHOOK_SIGNING_SECRET` or the explicit secret. * * Consumers verify authenticity by: * 1. Reading the raw request body as bytes (before JSON.parse). - * 2. Computing HMAC-SHA256(secret, rawBody) over those exact bytes. - * 3. Comparing the hex digest to the `X-COMEBACKHERE-Signature` header + * 2. Rejecting timestamps outside the five-minute tolerance. + * 3. Computing HMAC-SHA256(secret, timestamp + "." + rawBody). + * 4. Comparing the hex digest to the `X-COMEBACKHERE-Signature` header * using a constant-time comparison to prevent timing attacks. * * The signature is computed over the raw body bytes (Buffer/Uint8Array), not a @@ -22,10 +22,13 @@ * Encoding: lowercase hex */ -import { createHmac, timingSafeEqual } from "crypto" +import { createHmac, randomUUID, timingSafeEqual } from "crypto" +import { connectMongo, type WebhookDeliveryHistoryRecord, type WebhookReplayRecord } from "../db/mongo.js" /** The header name sent on every outbound webhook request. */ export const WEBHOOK_SIGNATURE_HEADER = "X-COMEBACKHERE-Signature" +export const WEBHOOK_TIMESTAMP_HEADER = "X-COMEBACKHERE-Timestamp" +export const WEBHOOK_LEGACY_SIGNATURE_HEADER = "X-COMEBACKHERE-Legacy-Signature" export interface WebhookPayload { event: string @@ -37,6 +40,15 @@ export interface WebhookDeliveryResult { status: number ok: boolean signature: string + legacySignature: string + timestamp: string + deliveryId: string +} + +export interface DispatchWebhookOptions { + merchantAddress?: string + correlationId?: string + replayOf?: string } /** @@ -107,6 +119,7 @@ export async function dispatchWebhook( payload: WebhookPayload, signingSecret?: string, fetchImpl: typeof fetch = fetch, + options: DispatchWebhookOptions = {}, ): Promise { const secret = signingSecret ?? process.env.WEBHOOK_SIGNING_SECRET if (!secret) { @@ -117,21 +130,98 @@ export async function dispatchWebhook( } const rawBody = JSON.stringify(payload) - const signature = signPayload(secret, rawBody) + const timestamp = Math.floor(Date.now() / 1000).toString() + const signature = signPayload(secret, `${timestamp}.${rawBody}`) + const legacySignature = signPayload(secret, rawBody) + const deliveryId = randomUUID() + let status: number | null = null + let failure: string | null = null - const response = await fetchImpl(url, { - method: "POST", - headers: { + try { + const headers: Record = { "Content-Type": "application/json", [WEBHOOK_SIGNATURE_HEADER]: signature, - }, - body: rawBody, - }) + [WEBHOOK_TIMESTAMP_HEADER]: timestamp, + [WEBHOOK_LEGACY_SIGNATURE_HEADER]: legacySignature, + } + if (options.correlationId) headers["X-Request-Id"] = options.correlationId + const response = await fetchImpl(url, { + method: "POST", + headers, + body: rawBody, + }) + status = response.status + if (!response.ok) failure = `HTTP ${response.status}` + return { + url, + status: response.status, + ok: response.ok, + signature, + legacySignature, + timestamp, + deliveryId, + } + } catch (err) { + failure = err instanceof Error ? err.message : String(err) + throw err + } finally { + await persistWebhookAttempt({ + deliveryId, + merchantAddress: options.merchantAddress, + endpoint: url, + payload, + status, + failure, + requestId: options.correlationId ?? null, + replayOf: options.replayOf, + }) + } +} + +async function persistWebhookAttempt(attempt: { + deliveryId: string + merchantAddress?: string + endpoint: string + payload: WebhookPayload + status: number | null + failure: string | null + requestId: string | null + replayOf?: string +}): Promise { + if (!attempt.merchantAddress) return + try { + const collection = (await connectMongo()).collection("webhook_deliveries") + const attemptedAt = new Date() + const replay: WebhookReplayRecord = { + replay_id: attempt.deliveryId, + request_id: attempt.requestId, + attempted_at: attemptedAt, + status: attempt.status !== null && attempt.status >= 200 && attempt.status < 300 ? "delivered" : "failed", + status_code: attempt.status, + error: attempt.failure, + } + + if (attempt.replayOf) { + await collection.updateOne( + { delivery_id: attempt.replayOf, merchant_address: attempt.merchantAddress }, + { $push: { replays: replay } }, + ) + return + } - return { - url, - status: response.status, - ok: response.ok, - signature, + await collection.insertOne({ + delivery_id: attempt.deliveryId, + merchant_address: attempt.merchantAddress, + endpoint: attempt.endpoint, + payload: attempt.payload, + status: replay.status, + attempts: 1, + last_status_code: attempt.status, + last_error: attempt.failure, + created_at: attemptedAt, + replays: [], + }) + } catch (err) { + console.error("[webhook] failed to persist delivery history:", err instanceof Error ? err.message : err) } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 08f3468..bc8e453 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -73,6 +73,32 @@ Checks Soroban RPC reachability and current ledger. --- +## Merchant authentication + +Invoice creation and webhook replay require a merchant API key. An admin +creates or rotates a key with `POST /api/merchant-keys`, sending `x-admin-key` +and a JSON body containing the merchant's Stellar address. The returned +`api_key` is shown only once; MongoDB stores only its SHA-256 hash. Creating a +new key revokes the merchant's prior active keys. Revoke a key with +`DELETE /api/merchant-keys/{keyId}` and the admin header. + +```http +POST /api/merchant-keys +X-Admin-Key: +Content-Type: application/json + +{"merchant_address":"G..."} +``` + +The `201` response contains `key_id`, `merchant_address`, `api_key`, and +`created_at`. The key is returned only at creation time; store it securely. +Revocation returns `204 No Content`. + +Use `Authorization: Bearer ` on merchant requests. Missing, invalid, +or revoked keys return `401 UNAUTHORIZED` in the standard error envelope. + +--- + ## Invoices ### `GET /invoices/:id` @@ -113,7 +139,6 @@ Create a new invoice by submitting `create_invoice` to the Soroban RPC. ```json { - "merchant_address": "G...", "token": "USDC", "amount": 1000000, "due_date": 1720000000 @@ -122,11 +147,13 @@ Create a new invoice by submitting `create_invoice` to the Soroban RPC. | Field | Type | Description | | ------------------ | ------ | ------------------------------------------------- | -| `merchant_address` | string | Valid Stellar public key (G…) | | `token` | string | Token identifier | | `amount` | number | Positive number (in stroops / smallest unit) | | `due_date` | number | Future Unix timestamp (seconds) for the due date | +The merchant identity is taken from the API key, never from the request body. +Include `Authorization: Bearer `. + **Response `201`** ```json @@ -141,6 +168,7 @@ Create a new invoice by submitting `create_invoice` to the Soroban RPC. | Status | Description | | ------ | -------------------------------------------------------------- | | `400` | Validation error — see `error.details` for field-level detail | +| `401` | Missing, invalid, or revoked merchant API key | | `422` | Soroban simulation or transaction failure | | `503` | Missing required environment variables | | `504` | Transaction confirmation timeout | @@ -811,10 +839,14 @@ can verify payload authenticity before processing it. | Header | Value | | --------------------------- | ---------------------------------------- | -| `X-COMEBACKHERE-Signature` | Lowercase hex-encoded HMAC-SHA256 digest | +| `X-COMEBACKHERE-Signature` | HMAC-SHA256 of `timestamp.rawBody`, lowercase hex | +| `X-COMEBACKHERE-Timestamp` | Unix timestamp in seconds | +| `X-COMEBACKHERE-Legacy-Signature` | Temporary body-only HMAC for one release | -The digest is computed over the **raw JSON request body** (exactly as sent over -the wire) using the `WEBHOOK_SIGNING_SECRET` environment variable as the key. +The primary digest is computed over `timestamp + "." + rawBody` (the exact raw +JSON request bytes) using `WEBHOOK_SIGNING_SECRET`. Reject a timestamp more than +five minutes old or in the future. The legacy header supports receiver +migration for one release and must not be used for freshness validation. ### Verification (Node.js example) @@ -824,9 +856,12 @@ import { createHmac, timingSafeEqual } from "crypto" function verifyWebhook( rawBody: string, // The unparsed request body string signature: string, // Value of X-COMEBACKHERE-Signature header + timestamp: string, // Value of X-COMEBACKHERE-Timestamp header secret: string, // Your WEBHOOK_SIGNING_SECRET ): boolean { - const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex") + const seconds = Number(timestamp) + if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false + const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex") const expectedBuf = Buffer.from(expected, "hex") const actualBuf = Buffer.from(signature, "hex") if (expectedBuf.length !== actualBuf.length) return false @@ -837,6 +872,13 @@ function verifyWebhook( Always use a **constant-time comparison** (e.g. `crypto.timingSafeEqual`) when comparing signatures to prevent timing side-channel attacks. +### Replay a failed delivery + +`POST /webhooks/{deliveryId}/replay` re-sends a stored failed webhook to its +original endpoint with a new timestamped signature. It requires the owning +merchant's API key. Replays are appended to the original delivery history with +their outcome and correlation ID; successful deliveries cannot be replayed. + ### Webhook event payload shape All events share a common `event` field plus event-specific fields: diff --git a/docs/webhooks.md b/docs/webhooks.md index 7ea950c..dad0ea2 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -27,23 +27,29 @@ logged and no retries are attempted. ## Signature verification -Every outbound webhook `POST` includes an `X-COMEBACKHERE-Signature` header -containing a lowercase hex-encoded HMAC-SHA256 digest of the **raw request -body** (the exact bytes sent over the wire), keyed by your -`WEBHOOK_SIGNING_SECRET`. +Every outbound webhook `POST` includes an `X-COMEBACKHERE-Timestamp` header and +an `X-COMEBACKHERE-Signature` header containing a lowercase hex-encoded +HMAC-SHA256 digest of `timestamp.rawBody`, keyed by your +`WEBHOOK_SIGNING_SECRET`. The timestamp is Unix seconds. Reject requests whose +timestamp differs from your current time by more than the recommended five +minutes. ### Algorithm summary 1. Read the raw request body **before** calling `JSON.parse()`. -2. Compute `HMAC-SHA256(secret, rawBody)` and hex-encode it. -3. Compare the result to the `X-COMEBACKHERE-Signature` header using a +2. Read the Unix timestamp from `X-COMEBACKHERE-Timestamp` and reject values + outside a five-minute tolerance. +3. Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)` and hex-encode it. +4. Compare the result to the `X-COMEBACKHERE-Signature` header using a **constant-time comparison** to prevent timing side-channel attacks. ### Header reference | Header | Value | | --------------------------- | -------------------------------------------- | -| `X-COMEBACKHERE-Signature` | Lowercase hex-encoded HMAC-SHA256 digest | +| `X-COMEBACKHERE-Signature` | HMAC-SHA256 of `timestamp.rawBody`, lowercase hex | +| `X-COMEBACKHERE-Timestamp` | Unix timestamp in seconds | +| `X-COMEBACKHERE-Legacy-Signature` | Temporary body-only HMAC for one-release migration | | `Content-Type` | `application/json` | | `X-Idempotency-Key` | Stable per-event key (see [Idempotency](#idempotency)) | | `X-Request-Id` | Correlation ID forwarded from the originating request (when available) | @@ -59,16 +65,20 @@ import { createHmac, timingSafeEqual } from "crypto" * * @param rawBody The unparsed request body string (read before JSON.parse). * @param signature The value of the X-COMEBACKHERE-Signature header. + * @param timestamp The value of the X-COMEBACKHERE-Timestamp header. * @param secret Your WEBHOOK_SIGNING_SECRET environment variable. */ function verifyWebhookSignature( rawBody: string, signature: string, + timestamp: string, secret: string, ): boolean { try { + const seconds = Number(timestamp) + if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false const expected = createHmac("sha256", secret) - .update(rawBody, "utf8") + .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex") const expectedBuf = Buffer.from(expected, "hex") @@ -89,17 +99,36 @@ function verifyWebhookSignature( ```python import hashlib import hmac - -def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool: - """Return True if the signature is a valid HMAC-SHA256 of raw_body.""" - expected = hmac.new( - secret.encode("utf-8"), - raw_body, - hashlib.sha256, - ).hexdigest() - return hmac.compare_digest(expected, signature) +import time + +def verify_webhook_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool: return ( + timestamp.isdigit() + and abs(time.time() - int(timestamp)) <= 300 + and hmac.compare_digest( + hmac.new( + secret.encode("utf-8"), + timestamp.encode("utf-8") + b"." + raw_body, + hashlib.sha256, + ).hexdigest(), + signature, + ) +) ``` +During the one-release migration window, receivers that have not yet deployed +timestamp verification can temporarily validate the body-only digest from +`X-COMEBACKHERE-Legacy-Signature`. Switch to the timestamped signature and +remove that fallback after upgrading. + +## Replay a failed delivery + +Merchant API keys can replay only their own failed deliveries. Send +`POST /webhooks/{deliveryId}/replay` with +`Authorization: Bearer `. The backend sends the stored payload +to its original endpoint with a fresh timestamped signature and appends the +attempt and correlation ID to delivery history. Successful deliveries cannot +be replayed through this endpoint. + > **Always use a constant-time comparison.** Standard string equality (`===`, > `==`) leaks information about how many bytes match, which can be exploited > by a timing attack.