diff --git a/README.md b/README.md index 293430c..bc0e817 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 @@ -66,6 +66,9 @@ function verifyWebhook(rawBody: string, signature: string, secret: string): bool } ``` +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/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 916a984..7f5c4b5 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -26,6 +26,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 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 3a0424f..730ca6a 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" @@ -183,12 +184,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({ @@ -227,12 +223,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({ 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 31de43d..d728079 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -634,18 +634,19 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy * 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" @@ -681,6 +682,12 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy * 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: @@ -700,15 +707,16 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy * 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, @@ -717,7 +725,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 ff27b84..5929ffe 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", @@ -317,7 +318,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", @@ -566,7 +567,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() @@ -655,7 +656,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 f9443fd..971c34b 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 eb51344..7346c44 100644 --- a/comebackhere-backend/src/services/treasury-indexer.ts +++ b/comebackhere-backend/src/services/treasury-indexer.ts @@ -259,6 +259,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 @@ -275,6 +276,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. 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 bc5c3ff..1c19c5d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -96,6 +96,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` @@ -209,7 +235,6 @@ Create a new invoice by submitting `create_invoice` to the Soroban RPC. ```json { - "merchant_address": "G...", "token": "USDC", "amount": 1000000, "due_date": 1720000000 @@ -218,11 +243,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 @@ -237,6 +264,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 | @@ -907,7 +935,9 @@ 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. It @@ -925,9 +955,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 diff --git a/docs/webhooks.md b/docs/webhooks.md index 065452f..353a01c 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -85,8 +85,10 @@ deduplication (see ### 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. 4. Compare lengths first — `timingSafeEqual` throws on a length mismatch, so a short or malformed header must be rejected before the comparison. @@ -122,16 +124,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") @@ -152,15 +158,20 @@ 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, + ) +) ``` ### Verification — Go