diff --git a/.pull_request/pr-105-description.md b/.pull_request/pr-105-description.md new file mode 100644 index 00000000..b9f57ecb --- /dev/null +++ b/.pull_request/pr-105-description.md @@ -0,0 +1,48 @@ +# feat: implement mobile money callback signature verification (#105) + +## Summary + +Adds provider-specific callback signature verification for MTN MoMo and Airtel Money inbound webhook callbacks. Unverified callbacks are rejected with HTTP 403 and logged as security anomaly events. + +## Changes + +### MTN MoMo (`src/middleware/mtnCallbackSignature.ts`) +- Fixed response code: unverified callbacks now return **403 Forbidden** (was 401) per acceptance criteria +- HMAC-SHA256 verification using `MTN_CALLBACK_SECRET` (subscription key) on the `X-Callback-Signature` header +- Supports both `sha256=` prefixed hex and plain base64 signature formats +- Timing-safe comparison via `timingSafeEqual` +- All rejection paths log a `security.anomaly` event with reason code + +### Airtel Money — new + +| File | Description | +|---|---| +| `src/middleware/airtelCallbackSignature.ts` | New middleware — validates `Authorization: Bearer {token}` against `AIRTEL_CALLBACK_SECRET` using `timingSafeEqual`. Missing = 403. Invalid = 403. Unconfigured = 500. All failures logged as security anomalies. | +| `src/routes/airtelCallbacks.ts` | New router — `ingestRateLimiter` → `verifyAirtelCallbackSignature` → `POST /callback` → `{ status: "accepted" }` | +| `src/config/appConfig.ts` | Added `providers.airtel.callbackSecret` config entry (env: `AIRTEL_CALLBACK_SECRET`) | +| `src/index.ts` | Mounted Airtel callback router at `app.use("/api/airtel", airtelCallbacksRouter)` | + +### Tests + +| File | Tests | +|---|---| +| `src/middleware/__tests__/airtelCallbackSignature.test.ts` | 5 unit tests: unconfigured secret (500), missing header (403), non-Bearer scheme (403), valid token (200), wrong token (403) | +| `src/routes/__tests__/airtelCallbacks.test.ts` | 4 integration tests via supertest: valid bearer (200), missing auth (403), wrong token (403), Basic scheme (403) | +| `src/middleware/__tests__/mtnCallbackSignature.test.ts` | Updated — expect 403 instead of 401 | +| `src/routes/__tests__/mtnCallbacks.test.ts` | Updated — expect 403 instead of 401 | + +## Environment Variables Required + +```env +MTN_CALLBACK_SECRET= +AIRTEL_CALLBACK_SECRET= +``` + +## Acceptance Criteria + +- ✅ MTN MoMo callbacks validate `X-Callback-Signature` header using HMAC-SHA256 with subscription key +- ✅ Airtel callbacks validate `Authorization: Bearer` token against expected shared secret +- ✅ Unverified callbacks return 403 and are logged as security events +- ✅ Verification logic is unit tested with known valid and invalid signature vectors + +closes #105 diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index af17dcce..2fab4953 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -99,6 +99,12 @@ export const configSchema = convict({ default: 1000000, env: "AIRTEL_MAX_AMOUNT", }, + callbackSecret: { + doc: "Airtel callback shared secret for verifying incoming callbacks", + format: String, + default: "", + env: "AIRTEL_CALLBACK_SECRET", + }, }, orange: { minAmount: { diff --git a/src/index.ts b/src/index.ts index d95a5bfc..f5880497 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ import { privacyRoutes } from "./routes/privacy"; import { developerDashboardRoutes } from "./routes/developerDashboard"; import { travelRuleRoutes } from "./routes/travelRule"; import mtnCallbacksRouter from "./routes/mtnCallbacks"; +import airtelCallbacksRouter from "./routes/airtelCallbacks"; import sep31Router from "./stellar/sep31"; import sep24Router from "./stellar/sep24"; import sep38Router from "./stellar/sep38"; @@ -372,6 +373,7 @@ app.use("/api/disputes", disputeRoutes); app.use("/api/stats", statsRoutes); app.use("/api/contacts", contactsRoutes); app.use("/api/mtn", mtnCallbacksRouter); +app.use("/api/airtel", airtelCallbacksRouter); app.use("/api/reports", reportsRoutes); app.use("/api/fees", feesRoutes); app.use("/api/users", userRoutes); diff --git a/src/middleware/__tests__/airtelCallbackSignature.test.ts b/src/middleware/__tests__/airtelCallbackSignature.test.ts new file mode 100644 index 00000000..2301bbec --- /dev/null +++ b/src/middleware/__tests__/airtelCallbackSignature.test.ts @@ -0,0 +1,129 @@ +import { NextFunction, Request, Response } from "express"; + +// Mock appConfig before importing the middleware +const mockGetConfigValue = jest.fn(); +jest.mock("../../config/appConfig", () => ({ + getConfigValue: mockGetConfigValue, +})); + +// Mock logger +const mockLogSecurityAnomaly = jest.fn(); +jest.mock("../../services/logger", () => ({ + getCurrentRequestIp: jest.fn(() => "1.2.3.4"), + logSecurityAnomaly: mockLogSecurityAnomaly, +})); + +import { verifyAirtelCallbackSignature } from "../airtelCallbackSignature"; + +const SECRET = "test-airtel-secret-token"; + +function makeReq(overrides: Partial = {}): Request { + return { + headers: {}, + body: {}, + method: "POST", + originalUrl: "/api/airtel/callback", + url: "/api/airtel/callback", + ...overrides, + } as unknown as Request; +} + +function makeRes(): Response { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res as Response; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetConfigValue.mockImplementation((key: string) => { + if (key === "providers.airtel.callbackSecret") return SECRET; + return undefined; + }); +}); + +describe("verifyAirtelCallbackSignature", () => { + describe("secret not configured", () => { + it("returns 500 and logs anomaly when secret is missing", async () => { + mockGetConfigValue.mockImplementation((key: string) => { + if (key === "providers.airtel.callbackSecret") return ""; + return undefined; + }); + + const req = makeReq(); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await verifyAirtelCallbackSignature(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: "Airtel callback verification not configured", + }); + expect(next).not.toHaveBeenCalled(); + expect(mockLogSecurityAnomaly).toHaveBeenCalledWith( + expect.objectContaining({ reason: "airtel_callback_secret_not_configured" }), + ); + }); + }); + + describe("missing Authorization header", () => { + it("rejects with 403 when Authorization header is absent", async () => { + const req = makeReq({ headers: {} }); + const next: NextFunction = jest.fn(); + + await expect( + verifyAirtelCallbackSignature(req, makeRes(), next), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(next).not.toHaveBeenCalled(); + expect(mockLogSecurityAnomaly).toHaveBeenCalledWith( + expect.objectContaining({ reason: "airtel_callback_token_missing" }), + ); + }); + + it("rejects with 403 when Authorization header is not Bearer", async () => { + const req = makeReq({ headers: { authorization: "Basic abc123" } }); + const next: NextFunction = jest.fn(); + + await expect( + verifyAirtelCallbackSignature(req, makeRes(), next), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe("valid token", () => { + it("calls next() for a valid bearer token", async () => { + const req = makeReq({ + headers: { authorization: `Bearer ${SECRET}` }, + }); + const next: NextFunction = jest.fn(); + + await verifyAirtelCallbackSignature(req, makeRes(), next); + + expect(next).toHaveBeenCalled(); + expect(mockLogSecurityAnomaly).not.toHaveBeenCalled(); + }); + }); + + describe("invalid token", () => { + it("rejects with 403 for a wrong bearer token", async () => { + const req = makeReq({ + headers: { authorization: "Bearer wrong-token-value" }, + }); + const next: NextFunction = jest.fn(); + + await expect( + verifyAirtelCallbackSignature(req, makeRes(), next), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(next).not.toHaveBeenCalled(); + expect(mockLogSecurityAnomaly).toHaveBeenCalledWith( + expect.objectContaining({ reason: "airtel_callback_token_invalid", headerPresent: true }), + ); + }); + }); +}); diff --git a/src/middleware/__tests__/mtnCallbackSignature.test.ts b/src/middleware/__tests__/mtnCallbackSignature.test.ts index 2476308a..8668540c 100644 --- a/src/middleware/__tests__/mtnCallbackSignature.test.ts +++ b/src/middleware/__tests__/mtnCallbackSignature.test.ts @@ -83,13 +83,13 @@ describe("verifyMtnCallbackSignature", () => { }); describe("signature header missing", () => { - it("throws 401 and logs anomaly when no signature header is present", async () => { + it("throws 403 and logs anomaly when no signature header is present", async () => { const req = makeReq({ headers: {} }); const next: NextFunction = jest.fn(); await expect( verifyMtnCallbackSignature(req, makeRes(), next), - ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + ).rejects.toMatchObject({ code: "FORBIDDEN" }); expect(next).not.toHaveBeenCalled(); expect(mockLogSecurityAnomaly).toHaveBeenCalledWith( @@ -165,7 +165,7 @@ describe("verifyMtnCallbackSignature", () => { }); describe("invalid signatures", () => { - it("throws 401 for a tampered payload", async () => { + it("throws 403 for a tampered payload", async () => { const rawBody = Buffer.from(PAYLOAD); const sig = hmacBase64("different-payload", SECRET); const req = makeReq({ @@ -176,7 +176,7 @@ describe("verifyMtnCallbackSignature", () => { await expect( verifyMtnCallbackSignature(req, makeRes(), next), - ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + ).rejects.toMatchObject({ code: "FORBIDDEN" }); expect(next).not.toHaveBeenCalled(); expect(mockLogSecurityAnomaly).toHaveBeenCalledWith( @@ -184,7 +184,7 @@ describe("verifyMtnCallbackSignature", () => { ); }); - it("throws 401 for a wrong secret", async () => { + it("throws 403 for a wrong secret", async () => { const rawBody = Buffer.from(PAYLOAD); const sig = hmacBase64(PAYLOAD, "wrong-secret"); const req = makeReq({ @@ -195,12 +195,12 @@ describe("verifyMtnCallbackSignature", () => { await expect( verifyMtnCallbackSignature(req, makeRes(), next), - ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + ).rejects.toMatchObject({ code: "FORBIDDEN" }); expect(next).not.toHaveBeenCalled(); }); - it("throws 401 for a signature with mismatched length", async () => { + it("throws 403 for a signature with mismatched length", async () => { const rawBody = Buffer.from(PAYLOAD); const req = makeReq({ headers: { "x-callback-signature": "short" }, @@ -210,7 +210,7 @@ describe("verifyMtnCallbackSignature", () => { await expect( verifyMtnCallbackSignature(req, makeRes(), next), - ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); it("logs anomaly with headerPresent=true for invalid signature", async () => { diff --git a/src/middleware/airtelCallbackSignature.ts b/src/middleware/airtelCallbackSignature.ts new file mode 100644 index 00000000..3338490f --- /dev/null +++ b/src/middleware/airtelCallbackSignature.ts @@ -0,0 +1,88 @@ +import { timingSafeEqual } from "crypto"; +import { NextFunction, Request, Response } from "express"; +import { getConfigValue } from "../config/appConfig"; +import { getCurrentRequestIp, logSecurityAnomaly } from "../services/logger"; +import { ERROR_CODES } from "../constants/errorCodes"; +import { createError } from "./errorHandler"; + +function getAirtelCallbackSecret(): string { + const secret = getConfigValue("providers.airtel.callbackSecret"); + return String(secret ?? "").trim(); +} + +function extractBearerToken(req: Request): string | undefined { + const authHeader = req.headers["authorization"] as string | undefined; + if (!authHeader) return undefined; + const parts = authHeader.split(" "); + if (parts.length === 2 && parts[0].toLowerCase() === "bearer") { + return parts[1]; + } + return undefined; +} + +function buildAirtelFailureEvent( + req: Request, + reason: string, + headerPresent: boolean, +): void { + logSecurityAnomaly({ + event: "security.anomaly", + timestamp: new Date().toISOString(), + path: req.originalUrl || req.url, + method: req.method, + ip: getCurrentRequestIp(req), + reason, + provider: "airtel", + headerPresent, + }); +} + +export async function verifyAirtelCallbackSignature( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const callbackSecret = getAirtelCallbackSecret(); + + if (!callbackSecret) { + buildAirtelFailureEvent(req, "airtel_callback_secret_not_configured", false); + res.status(500).json({ error: "Airtel callback verification not configured" }); + return; + } + + const token = extractBearerToken(req); + + if (!token) { + buildAirtelFailureEvent(req, "airtel_callback_token_missing", false); + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", + }); + } + + try { + const expectedBuf = Buffer.from(callbackSecret); + const incomingBuf = Buffer.from(token); + + const isValid = + expectedBuf.length === incomingBuf.length && + timingSafeEqual(expectedBuf, incomingBuf); + + if (!isValid) { + buildAirtelFailureEvent(req, "airtel_callback_token_invalid", true); + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", + }); + } + + next(); + } catch (error: any) { + // Re-throw structured errors (from createError) directly + if (error?.code === ERROR_CODES.FORBIDDEN) { + throw error; + } + buildAirtelFailureEvent(req, "airtel_callback_token_error", true); + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", + }); + } +} diff --git a/src/middleware/mtnCallbackSignature.ts b/src/middleware/mtnCallbackSignature.ts index 5a5da983..91113232 100644 --- a/src/middleware/mtnCallbackSignature.ts +++ b/src/middleware/mtnCallbackSignature.ts @@ -94,8 +94,8 @@ export async function verifyMtnCallbackSignature( if (!signature) { buildFailureEvent(req, "mtn_callback_signature_missing", false); - throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", { - error: "Unauthorized callback", + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", }); } @@ -105,16 +105,16 @@ export async function verifyMtnCallbackSignature( try { if (!verifySignature(payload, signature, callbackSecret)) { buildFailureEvent(req, "mtn_callback_signature_invalid", true); - throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", { - error: "Unauthorized callback", + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", }); } next(); } catch (error) { buildFailureEvent(req, "mtn_callback_signature_error", true); - throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", { - error: "Unauthorized callback", + throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", { + error: "Forbidden", }); } } diff --git a/src/routes/__tests__/airtelCallbacks.test.ts b/src/routes/__tests__/airtelCallbacks.test.ts new file mode 100644 index 00000000..6ae1c9da --- /dev/null +++ b/src/routes/__tests__/airtelCallbacks.test.ts @@ -0,0 +1,66 @@ +jest.mock("../../config/appConfig", () => ({ + getConfigValue: jest.fn((key: string) => { + if (key === "providers.airtel.callbackSecret") return "test-airtel-secret"; + return undefined; + }), +})); + +// Mock Redis for ingestRateLimiter +jest.mock("../../config/redis", () => ({ + redisClient: { + isOpen: false, + eval: jest.fn(), + }, +})); + +const request = require("supertest"); +const express = require("express"); +import airtelCallbacksRouter from "../airtelCallbacks"; + +describe("Airtel Callback Signature Verification", () => { + const SECRET = "test-airtel-secret"; + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use("/api/airtel", airtelCallbacksRouter); + // Express error handler + app.use((err: any, req: any, res: any, next: any) => { + res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); + }); + }); + + it("accepts a valid Airtel callback with correct bearer token", async () => { + const response = await request(app) + .post("/api/airtel/callback") + .set("Authorization", `Bearer ${SECRET}`) + .send({ status: "SUCCESS", transaction: { id: "tx-123" } }) + .expect(200); + + expect(response.body).toMatchObject({ status: "accepted" }); + }); + + it("rejects a callback with a missing Authorization header", async () => { + await request(app) + .post("/api/airtel/callback") + .send({ status: "SUCCESS" }) + .expect(403); + }); + + it("rejects a callback with an invalid bearer token", async () => { + await request(app) + .post("/api/airtel/callback") + .set("Authorization", "Bearer wrong-token") + .send({ status: "SUCCESS" }) + .expect(403); + }); + + it("rejects a callback with a non-Bearer auth scheme", async () => { + await request(app) + .post("/api/airtel/callback") + .set("Authorization", "Basic dXNlcjpwYXNz") + .send({ status: "SUCCESS" }) + .expect(403); + }); +}); diff --git a/src/routes/__tests__/mtnCallbacks.test.ts b/src/routes/__tests__/mtnCallbacks.test.ts index 0628192f..79a0f5cc 100644 --- a/src/routes/__tests__/mtnCallbacks.test.ts +++ b/src/routes/__tests__/mtnCallbacks.test.ts @@ -8,6 +8,14 @@ jest.mock("../../config/appConfig", () => ({ }), })); +// Mock Redis for ingestRateLimiter +jest.mock("../../config/redis", () => ({ + redisClient: { + isOpen: false, + eval: jest.fn(), + }, +})); + const request = require("supertest"); const express = require("express"); import mtnCallbacksRouter from "../mtnCallbacks"; @@ -30,6 +38,10 @@ describe("MTN Callback Signature Verification", () => { }), ); app.use("/api/mtn", mtnCallbacksRouter); + // Express error handler + app.use((err: any, req: any, res: any, next: any) => { + res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); + }); }); it("accepts a valid MTN callback signature", async () => { @@ -50,9 +62,7 @@ describe("MTN Callback Signature Verification", () => { const response = await request(app) .post("/api/mtn/callback") .send({ status: "incoming" }) - .expect(401); - - expect(response.body).toEqual({ error: "Unauthorized callback" }); + .expect(403); }); it("rejects a callback with an invalid signature", async () => { @@ -63,8 +73,6 @@ describe("MTN Callback Signature Verification", () => { .post("/api/mtn/callback") .set("X-Callback-Signature", invalidSignature) .send(payload) - .expect(401); - - expect(response.body).toEqual({ error: "Unauthorized callback" }); + .expect(403); }); }); diff --git a/src/routes/airtelCallbacks.ts b/src/routes/airtelCallbacks.ts new file mode 100644 index 00000000..6c25f943 --- /dev/null +++ b/src/routes/airtelCallbacks.ts @@ -0,0 +1,18 @@ +import { Router, Request, Response } from "express"; +import { verifyAirtelCallbackSignature } from "../middleware/airtelCallbackSignature"; +import { ingestRateLimiter } from "../middleware/ingestRateLimit"; + +const router = Router(); + +// Rate-limit ingest traffic before any heavier processing. +router.use(ingestRateLimiter); + +// Validate Airtel Authorization bearer token before processing. +router.use(verifyAirtelCallbackSignature); + +router.post("/callback", async (req: Request, res: Response) => { + // Future Airtel callback processing can be added here. + res.status(200).json({ status: "accepted" }); +}); + +export default router;