diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts new file mode 100644 index 00000000..7b57a644 --- /dev/null +++ b/src/crypto/encryption.ts @@ -0,0 +1,52 @@ +import { + randomBytes, + createCipheriv, + createDecipheriv, + createHash, +} from "crypto"; + +export interface Encrypted { + iv: string; // hex + ciphertext: string; // hex + authTag: string; // hex +} + +/** Derive a 32-byte AES key from a password using a single SHA-256. + * This is intentionally simple for tests/fuzzing. For production use a + * proper KDF (PBKDF2/Argon2/ HKDF) with salt and iterations. + */ +export function deriveKey(password: string): Buffer { + return createHash("sha256").update(password, "utf8").digest(); +} + +/** Encrypt a buffer with AES-256-GCM. Returns hex-encoded fields. */ +export function encryptAesGcm(plaintext: Buffer, key: Buffer): Encrypted { + if (key.length !== 32) throw new Error("key must be 32 bytes"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + iv: iv.toString("hex"), + ciphertext: ciphertext.toString("hex"), + authTag: authTag.toString("hex"), + }; +} + +/** Decrypt AES-256-GCM hex fields. Throws on auth failure. */ +export function decryptAesGcm(enc: Encrypted, key: Buffer): Buffer { + if (key.length !== 32) throw new Error("key must be 32 bytes"); + const iv = Buffer.from(enc.iv, "hex"); + const ciphertext = Buffer.from(enc.ciphertext, "hex"); + const authTag = Buffer.from(enc.authTag, "hex"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return plain; +} + +export default { + deriveKey, + encryptAesGcm, + decryptAesGcm, +}; diff --git a/src/middleware/fingerprint.ts b/src/middleware/fingerprint.ts index fa0c5602..e0ed408f 100644 --- a/src/middleware/fingerprint.ts +++ b/src/middleware/fingerprint.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { pool } from "../config/database"; +import { createHash } from "crypto"; declare module "express-serve-static-core" { interface Request { @@ -7,32 +8,52 @@ declare module "express-serve-static-core" { } } -// Utility to extract fingerprint from headers/params +export function hashString(value: string | null | undefined): string { + const v = value ?? ""; + return createHash("sha256").update(v, "utf8").digest("hex"); +} + +// Utility to extract fingerprint from headers/params and return a hashed value export function extractFingerprint(req: Request): string { - // Combine user-agent, accept-language, and custom device header - const userAgent = req.headers["user-agent"] || ""; - const acceptLanguage = req.headers["accept-language"] || ""; - const deviceId = req.headers["x-device-id"] || req.query.deviceId || ""; - return `${userAgent}|${acceptLanguage}|${deviceId}`; + const userAgent = Array.isArray(req.headers["user-agent"]) + ? req.headers["user-agent"][0] + : (req.headers["user-agent"] ?? ""); + const acceptLanguage = Array.isArray(req.headers["accept-language"]) + ? req.headers["accept-language"][0] + : (req.headers["accept-language"] ?? ""); + const deviceId = + (Array.isArray(req.headers["x-device-id"]) + ? req.headers["x-device-id"][0] + : req.headers["x-device-id"]) || + (req.query?.deviceId as string) || + ""; + + // Hash the combined fingerprint parts to avoid storing raw UA / language + const raw = `${userAgent}|${acceptLanguage}|${deviceId}`; + return hashString(raw); } // Middleware to collect and compare device fingerprints -export async function fingerprintMiddleware(req: Request, res: Response, next: NextFunction) { - const userId = req.body.userId || req.user?.id; // Adjust as per your auth +export async function fingerprintMiddleware( + req: Request, + res: Response, + next: NextFunction, +) { + const userId = (req.body as any)?.userId || (req as any).user?.id; // Adjust as per your auth if (!userId) return next(); const fingerprint = extractFingerprint(req); - // Check fingerprint history + // Check fingerprint history (store hashed fingerprint) const result = await pool.query( "SELECT * FROM device_fingerprints WHERE user_id = $1 AND fingerprint = $2", - [userId, fingerprint] + [userId, fingerprint], ); if (result.rows.length === 0) { // New device detected await pool.query( "INSERT INTO device_fingerprints (user_id, fingerprint) VALUES ($1, $2)", - [userId, fingerprint] + [userId, fingerprint], ); // TODO: Trigger email alert to user req.isNewDevice = true; diff --git a/src/services/logger.ts b/src/services/logger.ts index 4b629e32..1cab9410 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { extractFingerprint } from "../middleware/fingerprint"; +import { extractFingerprint, hashString } from "../middleware/fingerprint"; declare module "express-session" { interface SessionData { @@ -67,6 +67,13 @@ export function buildSessionAnomalyAuditEvent( ? userAgentHeader[0] : userAgentHeader; + // Hash the user agent before including in audit event + const userAgentHash = hashString( + Array.isArray(userAgent) + ? (userAgent[0] ?? String(userAgent)) + : String(userAgent), + ); + return { event: "session.ip_mismatch", timestamp: new Date().toISOString(), @@ -77,7 +84,7 @@ export function buildSessionAnomalyAuditEvent( currentIp, suspicious: true, mismatchCount, - userAgent, + userAgent: userAgentHash, }; } @@ -94,6 +101,13 @@ export function buildSessionFingerprintAnomalyAuditEvent( ? userAgentHeader[0] : userAgentHeader; + // Hash the user agent before including in audit event + const userAgentHash = hashString( + Array.isArray(userAgent) + ? (userAgent[0] ?? String(userAgent)) + : String(userAgent), + ); + return { event: "session.fingerprint_mismatch", timestamp: new Date().toISOString(), @@ -104,7 +118,7 @@ export function buildSessionFingerprintAnomalyAuditEvent( currentFingerprint, suspicious: true, mismatchCount, - userAgent, + userAgent: userAgentHash, }; } @@ -135,7 +149,7 @@ export function logSecurityAnomaly( export function sessionAnomalyLogger( req: Request, - _res: Response, + res: Response, next: NextFunction, ): void { if (!req.session) { @@ -145,23 +159,26 @@ export function sessionAnomalyLogger( const currentIp = getCurrentRequestIp(req); if (currentIp) { - const previousIp = req.session.sessionIp; + const previousIpHash = req.session.sessionIp; + const currentIpHash = hashString(currentIp); - if (!previousIp) { - req.session.sessionIp = currentIp; - } else if (previousIp !== currentIp) { + if (!previousIpHash) { + // store hashed IP rather than raw value + req.session.sessionIp = currentIpHash; + } else if (previousIpHash !== currentIpHash) { const mismatchCount = (req.session.sessionIpMismatchCount ?? 0) + 1; req.session.sessionIpMismatchCount = mismatchCount; req.session.suspicious = true; req.session.suspiciousReason = "session_ip_mismatch"; req.session.lastSessionAnomalyAt = new Date().toISOString(); - req.session.sessionIp = currentIp; + // update stored IP hash + req.session.sessionIp = currentIpHash; logSessionAnomaly( buildSessionAnomalyAuditEvent( req as Request & { sessionID: string }, - previousIp, - currentIp, + previousIpHash ?? "", + currentIpHash, mismatchCount, ), ); @@ -175,7 +192,8 @@ export function sessionAnomalyLogger( if (!previousFingerprint) { req.session.sessionFingerprint = currentFingerprint; } else if (previousFingerprint !== currentFingerprint) { - const mismatchCount = (req.session.sessionFingerprintMismatchCount ?? 0) + 1; + const mismatchCount = + (req.session.sessionFingerprintMismatchCount ?? 0) + 1; req.session.sessionFingerprintMismatchCount = mismatchCount; req.session.suspicious = true; req.session.suspiciousReason = "session_fingerprint_mismatch"; @@ -196,7 +214,12 @@ export function sessionAnomalyLogger( console.error("Failed to destroy hijacked session:", err); } }); - res.status(401).json({ error: "Session invalidated due to suspicious activity. Please log in again." }); + res + .status(401) + .json({ + error: + "Session invalidated due to suspicious activity. Please log in again.", + }); return; } } diff --git a/src/services/mobilemoney/providers/airtel.ts b/src/services/mobilemoney/providers/airtel.ts index 1cfceec5..66371aa7 100644 --- a/src/services/mobilemoney/providers/airtel.ts +++ b/src/services/mobilemoney/providers/airtel.ts @@ -7,6 +7,7 @@ import axios, { import * as fs from "fs"; import * as path from "path"; import logger from "../../../utils/logger"; +import { maskPII } from "../../../utils/masking"; // ============================================================================ // TYPES & INTERFACES @@ -246,7 +247,7 @@ export class AirtelService { ) { const log = requestId ? logger.child({ requestId }) : logger; log.info( - { phoneNumber, amount, mode: this.mode }, + maskPII({ phoneNumber, amount, mode: this.mode }), "Airtel: Requesting payment", ); const startTime = Date.now(); @@ -278,7 +279,7 @@ export class AirtelService { const duration = Date.now() - startTime; log.info( - { duration, success: response.success }, + maskPII({ duration, success: response.success }), "Airtel: Payment request completed", ); @@ -290,7 +291,7 @@ export class AirtelService { } catch (error: any) { const duration = Date.now() - startTime; log.error( - { duration, error: error.message }, + maskPII({ duration, error: error.message }), "Airtel: Payment request failed", ); return { @@ -304,7 +305,7 @@ export class AirtelService { async sendPayout(phoneNumber: string, amount: string, requestId?: string) { const log = requestId ? logger.child({ requestId }) : logger; log.info( - { phoneNumber, amount, mode: this.mode }, + maskPII({ phoneNumber, amount, mode: this.mode }), "Airtel: Sending payout", ); const startTime = Date.now(); @@ -331,7 +332,7 @@ export class AirtelService { const duration = Date.now() - startTime; log.info( - { duration, success: response.success }, + maskPII({ duration, success: response.success }), "Airtel: Payout completed", ); @@ -342,7 +343,10 @@ export class AirtelService { }; } catch (error: any) { const duration = Date.now() - startTime; - log.error({ duration, error: error.message }, "Airtel: Payout failed"); + log.error( + maskPII({ duration, error: error.message }), + "Airtel: Payout failed", + ); return { success: false, error, diff --git a/src/services/mobilemoney/providers/mock.ts b/src/services/mobilemoney/providers/mock.ts index e04a0998..7604dddc 100644 --- a/src/services/mobilemoney/providers/mock.ts +++ b/src/services/mobilemoney/providers/mock.ts @@ -1,10 +1,21 @@ -import { MobileMoneyProvider, ProviderTransactionStatus } from "../mobileMoneyService"; +import { + MobileMoneyProvider, + ProviderTransactionStatus, +} from "../mobileMoneyService"; import logger from "../../../utils/logger"; +import { maskPII } from "../../../utils/masking"; export class MockProvider implements MobileMoneyProvider { - async requestPayment(phoneNumber: string, amount: string, requestId?: string) { + async requestPayment( + phoneNumber: string, + amount: string, + requestId?: string, + ) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "MockProvider: Requesting payment"); + log.info( + maskPII({ phoneNumber, amount }), + "MockProvider: Requesting payment", + ); return { success: true, data: { @@ -16,7 +27,7 @@ export class MockProvider implements MobileMoneyProvider { async sendPayout(phoneNumber: string, amount: string, requestId?: string) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "MockProvider: Sending payout"); + log.info(maskPII({ phoneNumber, amount }), "MockProvider: Sending payout"); return { success: true, data: { @@ -26,8 +37,10 @@ export class MockProvider implements MobileMoneyProvider { }; } - async getTransactionStatus(referenceId: string): Promise<{ status: ProviderTransactionStatus }> { - logger.info({ referenceId }, "MockProvider: Checking status"); + async getTransactionStatus( + referenceId: string, + ): Promise<{ status: ProviderTransactionStatus }> { + logger.info(maskPII({ referenceId }), "MockProvider: Checking status"); return { status: "completed" }; } } diff --git a/src/services/mobilemoney/providers/mtn.ts b/src/services/mobilemoney/providers/mtn.ts index 4a88db23..737e6703 100644 --- a/src/services/mobilemoney/providers/mtn.ts +++ b/src/services/mobilemoney/providers/mtn.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { randomUUID } from "crypto"; import logger from "../../../utils/logger"; +import { maskPII } from "../../../utils/masking"; interface MtnBalanceResponse { availableBalance?: string | number; @@ -97,9 +98,13 @@ export class MTNProvider { } } - async requestPayment(phoneNumber: string, amount: string, requestId?: string) { + async requestPayment( + phoneNumber: string, + amount: string, + requestId?: string, + ) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "MTN: Requesting payment"); + log.info(maskPII({ phoneNumber, amount }), "MTN: Requesting payment"); const startTime = Date.now(); try { @@ -122,31 +127,37 @@ export class MTNProvider { ); const duration = Date.now() - startTime; - log.info({ duration, status: response.status }, "MTN: Payment request successful"); + log.info( + maskPII({ duration, status: response.status }), + "MTN: Payment request successful", + ); - return { - success: true, + return { + success: true, data: response.data, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } catch (error: any) { const duration = Date.now() - startTime; - log.error({ - duration, - error: error.message, - response: error.response?.data - }, "MTN: Payment request failed"); - return { - success: false, + log.error( + maskPII({ + duration, + error: error.message, + response: error.response?.data, + }), + "MTN: Payment request failed", + ); + return { + success: false, error, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } } async sendPayout(phoneNumber: string, amount: string, requestId?: string) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "MTN: Sending payout"); + log.info(maskPII({ phoneNumber, amount }), "MTN: Sending payout"); return { success: true }; } @@ -154,10 +165,17 @@ export class MTNProvider { * MTN B2B Batch Payout - Process up to 50 payouts in a single API call. * This provides significant performance gains for high-volume payout operations. */ - async sendBatchPayout(items: BatchPayoutItem[], requestId?: string): Promise<{ success: boolean; results: BatchPayoutResult[]; error?: unknown }> { + async sendBatchPayout( + items: BatchPayoutItem[], + requestId?: string, + ): Promise<{ + success: boolean; + results: BatchPayoutResult[]; + error?: unknown; + }> { const log = requestId ? logger.child({ requestId }) : logger; const MAX_BATCH_SIZE = 50; - + if (items.length === 0) { return { success: true, results: [] }; } @@ -165,28 +183,33 @@ export class MTNProvider { if (items.length > MAX_BATCH_SIZE) { return { success: false, - results: items.map(item => ({ + results: items.map((item) => ({ referenceId: item.referenceId, success: false, error: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}`, })), - error: new Error(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`), + error: new Error( + `Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`, + ), }; } - log.info({ itemCount: items.length }, "MTN: Starting batch payout"); + log.info( + maskPII({ itemCount: items.length }), + "MTN: Starting batch payout", + ); const startTime = Date.now(); try { const token = await this.getAccessToken(); const batchReference = `BATCH-${randomUUID()}`; - + // MTN disbursement batch API endpoint const response = await axios.post( `${this.baseUrl}/disbursement/v2_0/batch-payout`, { batchReference, - items: items.map(item => ({ + items: items.map((item) => ({ referenceId: item.referenceId, amount: item.amount, currency: "XAF", @@ -210,11 +233,11 @@ export class MTNProvider { // Process partial success response const responseItems = response.data?.items ?? []; - const results: BatchPayoutResult[] = items.map(item => { + const results: BatchPayoutResult[] = items.map((item) => { const responseItem = responseItems.find( - (r: { referenceId: string }) => r.referenceId === item.referenceId + (r: { referenceId: string }) => r.referenceId === item.referenceId, ); - + if (!responseItem) { return { referenceId: item.referenceId, @@ -227,43 +250,54 @@ export class MTNProvider { return { referenceId: item.referenceId, success: status === "SUCCESSFUL" || status === "SUCCESS", - error: status !== "SUCCESSFUL" && status !== "SUCCESS" - ? responseItem.errorReason || responseItem.message || `Status: ${status}` - : undefined, - providerReference: responseItem.financialTransactionId || responseItem.transactionId, + error: + status !== "SUCCESSFUL" && status !== "SUCCESS" + ? responseItem.errorReason || + responseItem.message || + `Status: ${status}` + : undefined, + providerReference: + responseItem.financialTransactionId || responseItem.transactionId, }; }); - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; - log.info({ - duration, - successCount, - failureCount, - batchReference - }, "MTN: Batch payout completed"); + log.info( + maskPII({ + duration, + successCount, + failureCount, + batchReference, + }), + "MTN: Batch payout completed", + ); return { success: successCount > 0 || failureCount === 0, results, - error: failureCount > 0 && successCount === 0 - ? new Error("All batch items failed") - : undefined, + error: + failureCount > 0 && successCount === 0 + ? new Error("All batch items failed") + : undefined, }; } catch (error: any) { const duration = Date.now() - startTime; const errorMessage = error.message || "Batch payout request failed"; - - log.error({ - duration, - error: errorMessage, - itemCount: items.length - }, "MTN: Batch payout failed"); + + log.error( + maskPII({ + duration, + error: errorMessage, + itemCount: items.length, + }), + "MTN: Batch payout failed", + ); return { success: false, - results: items.map(item => ({ + results: items.map((item) => ({ referenceId: item.referenceId, success: false, error: errorMessage, @@ -288,9 +322,7 @@ export class MTNProvider { }, }, ); - const providerStatus = String( - response.data?.status ?? "", - ).toUpperCase(); + const providerStatus = String(response.data?.status ?? "").toUpperCase(); if (providerStatus === "SUCCESSFUL") return { status: "completed" }; if (providerStatus === "FAILED") return { status: "failed" }; if (providerStatus === "PENDING") return { status: "pending" }; diff --git a/src/services/mobilemoney/providers/orange.ts b/src/services/mobilemoney/providers/orange.ts index d3d836b1..42c76d55 100644 --- a/src/services/mobilemoney/providers/orange.ts +++ b/src/services/mobilemoney/providers/orange.ts @@ -2,6 +2,7 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import * as fs from "fs"; import * as path from "path"; import logger from "../../../utils/logger"; +import { maskPII } from "../../../utils/masking"; import { Browser, BrowserContext, Page, chromium } from "playwright"; type OrangeOperation = "payment" | "payout"; @@ -357,7 +358,7 @@ export class OrangeProvider { ): Promise { const log = requestId ? logger.child({ requestId }) : logger; log.info( - { phoneNumber, amount, operation, mode: this.mode }, + maskPII({ phoneNumber, amount, operation, mode: this.mode }), "Orange: Executing operation", ); const startTime = Date.now(); @@ -394,7 +395,7 @@ export class OrangeProvider { const duration = Date.now() - startTime; log.info( - { duration, success: response.success !== false }, + maskPII({ duration, success: response.success !== false }), "Orange: Operation completed", ); return response; diff --git a/src/services/mobilemoney/providers/vodacom.ts b/src/services/mobilemoney/providers/vodacom.ts index 819c1934..5367324c 100644 --- a/src/services/mobilemoney/providers/vodacom.ts +++ b/src/services/mobilemoney/providers/vodacom.ts @@ -1,12 +1,13 @@ import axios, { AxiosInstance } from "axios"; import crypto from "crypto"; import logger from "../../../utils/logger"; +import { maskPII } from "../../../utils/masking"; function encrypt(data: string, publicKeyPem: string): string { if (!publicKeyPem) { throw new Error("Vodacom Provider: Public key is missing or empty"); } - + let formattedKey = publicKeyPem.trim(); if (!formattedKey.includes("-----BEGIN PUBLIC KEY-----")) { formattedKey = `-----BEGIN PUBLIC KEY-----\n${formattedKey}\n-----END PUBLIC KEY-----`; @@ -18,7 +19,7 @@ function encrypt(data: string, publicKeyPem: string): string { key: formattedKey, padding: crypto.constants.RSA_PKCS1_PADDING, }, - buffer + buffer, ); return encrypted.toString("base64"); } @@ -31,26 +32,28 @@ export class VodacomProvider { private market: string; private currency: string; private client: AxiosInstance; - + private sessionToken: string | null = null; private sessionTokenExpiry = 0; constructor() { this.apiKey = process.env.VODACOM_API_KEY || ""; this.publicKey = process.env.VODACOM_PUBLIC_KEY || ""; - this.serviceProviderCode = process.env.VODACOM_SERVICE_PROVIDER_CODE || "000000"; - this.baseUrl = process.env.VODACOM_BASE_URL || "https://sandbox.openapi.m-pesa.com"; + this.serviceProviderCode = + process.env.VODACOM_SERVICE_PROVIDER_CODE || "000000"; + this.baseUrl = + process.env.VODACOM_BASE_URL || "https://sandbox.openapi.m-pesa.com"; this.market = process.env.VODACOM_MARKET || "vodacomTZN"; this.currency = process.env.VODACOM_CURRENCY || "TZS"; - + this.client = axios.create({ baseURL: this.baseUrl, timeout: 10000, headers: { "Content-Type": "application/json", - "Accept": "application/json", - "Origin": "*", - } + Accept: "application/json", + Origin: "*", + }, }); } @@ -60,25 +63,26 @@ export class VodacomProvider { } if (!this.apiKey || !this.publicKey) { - throw new Error("Vodacom Provider: VODACOM_API_KEY and VODACOM_PUBLIC_KEY must be configured"); + throw new Error( + "Vodacom Provider: VODACOM_API_KEY and VODACOM_PUBLIC_KEY must be configured", + ); } const encryptedKey = encrypt(this.apiKey, this.publicKey); - - const response = await this.client.get( - `/${this.market}/getSession/`, - { - headers: { - Authorization: `Bearer ${encryptedKey}`, - } - } - ); + + const response = await this.client.get(`/${this.market}/getSession/`, { + headers: { + Authorization: `Bearer ${encryptedKey}`, + }, + }); const sessionID = response.data?.output_SessionID; const responseCode = response.data?.output_ResponseCode; if (responseCode !== "INS-0" || !sessionID) { - throw new Error(`Vodacom getSession failed with code ${responseCode}: ${response.data?.output_ResponseDesc || "Unknown error"}`); + throw new Error( + `Vodacom getSession failed with code ${responseCode}: ${response.data?.output_ResponseDesc || "Unknown error"}`, + ); } this.sessionToken = sessionID; @@ -87,9 +91,13 @@ export class VodacomProvider { return this.sessionToken; } - async requestPayment(phoneNumber: string, amount: string, requestId?: string) { + async requestPayment( + phoneNumber: string, + amount: string, + requestId?: string, + ) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "Vodacom: Requesting payment"); + log.info(maskPII({ phoneNumber, amount }), "Vodacom: Requesting payment"); const startTime = Date.now(); try { @@ -112,37 +120,48 @@ export class VodacomProvider { { headers: { Authorization: `Bearer ${encryptedToken}`, - } - } + }, + }, ); const duration = Date.now() - startTime; const code = response.data?.output_ResponseCode; - + if (code === "INS-0") { - log.info({ duration, transactionId: response.data?.output_TransactionID }, "Vodacom: Payment request successful"); + log.info( + maskPII({ + duration, + transactionId: response.data?.output_TransactionID, + }), + "Vodacom: Payment request successful", + ); return { success: true, data: response.data, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } else { - throw new Error(`C2B failed with code ${code}: ${response.data?.output_ResponseDesc || "Unknown error"}`); + throw new Error( + `C2B failed with code ${code}: ${response.data?.output_ResponseDesc || "Unknown error"}`, + ); } } catch (error: any) { const duration = Date.now() - startTime; - log.error({ duration, error: error.message }, "Vodacom: Payment request failed"); + log.error( + maskPII({ duration, error: error.message }), + "Vodacom: Payment request failed", + ); return { success: false, error: error, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } } async sendPayout(phoneNumber: string, amount: string, requestId?: string) { const log = requestId ? logger.child({ requestId }) : logger; - log.info({ phoneNumber, amount }, "Vodacom: Sending payout"); + log.info(maskPII({ phoneNumber, amount }), "Vodacom: Sending payout"); const startTime = Date.now(); try { @@ -165,30 +184,41 @@ export class VodacomProvider { { headers: { Authorization: `Bearer ${encryptedToken}`, - } - } + }, + }, ); const duration = Date.now() - startTime; const code = response.data?.output_ResponseCode; if (code === "INS-0") { - log.info({ duration, transactionId: response.data?.output_TransactionID }, "Vodacom: Payout request successful"); + log.info( + maskPII({ + duration, + transactionId: response.data?.output_TransactionID, + }), + "Vodacom: Payout request successful", + ); return { success: true, data: response.data, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } else { - throw new Error(`B2C failed with code ${code}: ${response.data?.output_ResponseDesc || "Unknown error"}`); + throw new Error( + `B2C failed with code ${code}: ${response.data?.output_ResponseDesc || "Unknown error"}`, + ); } } catch (error: any) { const duration = Date.now() - startTime; - log.error({ duration, error: error.message }, "Vodacom: Payout request failed"); + log.error( + maskPII({ duration, error: error.message }), + "Vodacom: Payout request failed", + ); return { success: false, error: error, - providerResponseTimeMs: duration + providerResponseTimeMs: duration, }; } } @@ -210,14 +240,20 @@ export class VodacomProvider { input_QueryReference: referenceId, input_ServiceProviderCode: this.serviceProviderCode, input_ThirdPartyConversationID: `VODA-QUERY-${Date.now()}`, - } - } + }, + }, ); const code = response.data?.output_ResponseCode; if (code === "INS-0") { - const txStatus = String(response.data?.output_TransactionStatus || "").toUpperCase(); - if (txStatus === "SUCCESSFUL" || txStatus === "SUCCESS" || txStatus === "COMPLETED") { + const txStatus = String( + response.data?.output_TransactionStatus || "", + ).toUpperCase(); + if ( + txStatus === "SUCCESSFUL" || + txStatus === "SUCCESS" || + txStatus === "COMPLETED" + ) { return { status: "completed" }; } else if (txStatus === "FAILED" || txStatus === "FAIL") { return { status: "failed" }; diff --git a/src/utils/masking.ts b/src/utils/masking.ts index 1cd605ad..992fd146 100644 --- a/src/utils/masking.ts +++ b/src/utils/masking.ts @@ -10,7 +10,11 @@ export function maskPhoneNumber(phone: string): string { if (!phone) return ""; const cleaned = phone.trim(); if (cleaned.length <= 6) return cleaned; - return `${cleaned.slice(0, 4)}***${cleaned.slice(-2)}`; + const prefix = cleaned.slice(0, 4); + const suffix = cleaned.slice(-2); + const middleLen = Math.max(0, cleaned.length - prefix.length - suffix.length); + const stars = "*".repeat(middleLen); + return `${prefix}${stars}${suffix}`; } /** @@ -21,9 +25,8 @@ export function maskEmail(email: string): string { if (!email) return ""; const [localPart, domain] = email.split("@"); if (!domain) return email; - const maskedLocal = localPart.length <= 2 - ? `${localPart}***` - : `${localPart.slice(0, 2)}***`; + const maskedLocal = + localPart.length <= 2 ? `${localPart}***` : `${localPart.slice(0, 2)}***`; return `${maskedLocal}@${domain}`; } @@ -40,7 +43,10 @@ export function maskStellarAddress(address: string): string { /** * General purpose masking utility. */ -export function maskSensitiveData(data: string, type: "phone" | "email" | "stellar"): string { +export function maskSensitiveData( + data: string, + type: "phone" | "email" | "stellar", +): string { if (!data) return ""; switch (type) { case "phone": @@ -53,3 +59,80 @@ export function maskSensitiveData(data: string, type: "phone" | "email" | "stell return data; } } + +/** + * Mask PII in a value. If given an object, mask common PII fields recursively. + * - phone numbers (keys: phone, phoneNumber, msisdn) are masked with maskPhoneNumber + * - names (keys: name, customerName, firstName, lastName) are masked by keeping first/last char + */ +export function maskPII(value: any): any { + if (value == null) return value; + + if (typeof value === "string") { + // Detect phone-like strings (start with + followed by digits, or long digit string) + const trimmed = value.trim(); + if (/^\+?\d{8,}$/.test(trimmed)) { + return maskPhoneNumber(trimmed); + } + // For generic short strings, mask names by hiding interior letters of words + return trimmed + .split(/(\s+)/) + .map((part) => { + if (/^\s+$/.test(part)) return part; + if (part.length <= 2) return "*".repeat(part.length); + return part[0] + "*".repeat(part.length - 2) + part[part.length - 1]; + }) + .join(""); + } + + if (Array.isArray(value)) { + return value.map((v) => maskPII(v)); + } + + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + const lk = k.toLowerCase(); + if (v == null) { + out[k] = v; + continue; + } + if ( + lk === "phone" || + lk === "phonenumber" || + lk === "msisdn" || + lk === "phone_number" + ) { + out[k] = typeof v === "string" ? maskPhoneNumber(v) : v; + continue; + } + if ( + lk === "name" || + lk === "customername" || + lk === "firstname" || + lk === "lastname" || + lk === "full_name" + ) { + out[k] = typeof v === "string" ? maskPII(String(v)) : v; + continue; + } + // nested payer/payee objects with partyId + if ( + (lk === "payer" || + lk === "payee" || + lk === "subscriber" || + lk === "payeeinfo") && + typeof v === "object" + ) { + out[k] = maskPII(v); + continue; + } + // for other keys, recurse + out[k] = maskPII(v); + } + return out; + } + + // primitives (number, boolean) return as-is + return value; +} diff --git a/tests/crypto/encryption.fuzz.test.ts b/tests/crypto/encryption.fuzz.test.ts new file mode 100644 index 00000000..9a4d1d58 --- /dev/null +++ b/tests/crypto/encryption.fuzz.test.ts @@ -0,0 +1,68 @@ +import fc from "fast-check"; +import { + deriveKey, + encryptAesGcm, + decryptAesGcm, +} from "../../src/crypto/encryption"; + +describe("encryption fuzz tests (fast-check)", () => { + test("roundtrip: decrypt(encrypt(p, key)) === p", () => { + fc.assert( + fc.property(fc.uint8Array(), fc.string({ maxLength: 32 }), (arr, pwd) => { + const plain = Buffer.from(arr); + const key = deriveKey(pwd); + const enc = encryptAesGcm(plain, key); + const out = decryptAesGcm(enc, key); + expect(out.equals(plain)).toBe(true); + }), + { numRuns: 200 }, + ); + }); + + test("tampered ciphertext or tag fails to decrypt", () => { + fc.assert( + fc.property(fc.uint8Array(), fc.string({ maxLength: 32 }), (arr, pwd) => { + const plain = Buffer.from(arr); + const key = deriveKey(pwd); + const enc = encryptAesGcm(plain, key); + + // mutate ciphertext + const badEnc1 = { ...enc, ciphertext: flipHex(enc.ciphertext) }; + expect(() => decryptAesGcm(badEnc1, key)).toThrow(); + + // mutate auth tag + const badEnc2 = { ...enc, authTag: flipHex(enc.authTag) }; + expect(() => decryptAesGcm(badEnc2, key)).toThrow(); + }), + { numRuns: 100 }, + ); + }); + + test("wrong key fails to decrypt", () => { + fc.assert( + fc.property( + fc.uint8Array(), + fc.string({ maxLength: 32 }), + fc.string({ maxLength: 32 }), + (arr, pwd, wrong) => { + // ensure wrong key is different + fc.pre(pwd !== wrong); + const plain = Buffer.from(arr); + const key = deriveKey(pwd); + const keyWrong = deriveKey(wrong); + const enc = encryptAesGcm(plain, key); + expect(() => decryptAesGcm(enc, keyWrong)).toThrow(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +function flipHex(hex: string): string { + if (!hex || hex.length < 2) return hex; + // flip the first byte by xoring 0xff + const buf = Buffer.from(hex, "hex"); + buf[0] = buf[0] ^ 0xff; + return buf.toString("hex"); +}