Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/crypto/encryption.ts
Original file line number Diff line number Diff line change
@@ -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,
};
43 changes: 32 additions & 11 deletions src/middleware/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import { Request, Response, NextFunction } from "express";
import { pool } from "../config/database";
import { createHash } from "crypto";

declare module "express-serve-static-core" {
interface Request {
isNewDevice?: boolean;
}
}

// 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;
Expand Down
49 changes: 36 additions & 13 deletions src/services/logger.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(),
Expand All @@ -77,7 +84,7 @@ export function buildSessionAnomalyAuditEvent(
currentIp,
suspicious: true,
mismatchCount,
userAgent,
userAgent: userAgentHash,
};
}

Expand All @@ -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(),
Expand All @@ -104,7 +118,7 @@ export function buildSessionFingerprintAnomalyAuditEvent(
currentFingerprint,
suspicious: true,
mismatchCount,
userAgent,
userAgent: userAgentHash,
};
}

Expand Down Expand Up @@ -135,7 +149,7 @@ export function logSecurityAnomaly(

export function sessionAnomalyLogger(
req: Request,
_res: Response,
res: Response,
next: NextFunction,
): void {
if (!req.session) {
Expand All @@ -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,
),
);
Expand All @@ -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";
Expand All @@ -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;
}
}
Expand Down
16 changes: 10 additions & 6 deletions src/services/mobilemoney/providers/airtel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
);

Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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",
);

Expand All @@ -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,
Expand Down
25 changes: 19 additions & 6 deletions src/services/mobilemoney/providers/mock.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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: {
Expand All @@ -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" };
}
}
Loading
Loading