From 2145f736c749edffddfc1d1a4400ae6d877cc6c1 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Thu, 30 Jul 2026 04:48:13 +0000 Subject: [PATCH 1/4] feat(logging): mask PII (emails, phone numbers) in logs and error responses --- src/middleware/errorHandler.ts | 5 ++- src/utils/logger.ts | 11 +++++ src/utils/piiMask.ts | 80 ++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/utils/piiMask.ts diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index dbadcfb5..5479d549 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -4,6 +4,7 @@ import { ERROR_CODES, getHttpStatus } from "../constants/errorCodes"; import { getLocalizedMessage } from "../locales/messages"; import { resolveLocale, resolveLocaleFromRequest } from "../utils/i18n"; import logger from "../utils/logger"; +import { maskPii } from "../utils/piiMask"; /** * Extended Error interface with error-specific properties. @@ -182,7 +183,7 @@ export const errorHandler = ( statusCode, }, 'Request Error'); - const details = extractLegacyDetails(err); + const details = maskPii(extractLegacyDetails(err)) as Record; const body: ErrorResponse & { statusCode: number; error?: string } = { code: errorCode, message: localizedMessage, @@ -196,7 +197,7 @@ export const errorHandler = ( if (details && typeof details === "object" && typeof details.error === "string") { body.error = details.error; } else if (err.message) { - body.error = err.message; + body.error = maskPii(err.message) as string; } else { body.error = englishMessage; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 4ed28e9a..e5c9dc11 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,6 +1,7 @@ import pino, { Logger, TransportTargetOptions } from 'pino'; import os from 'os'; import { REDACT_KEYS } from './redact'; +import { maskPii } from './piiMask'; /** * Centralized Pino Logger — feature/centralized-logging @@ -127,6 +128,16 @@ const logger: Logger = pino( // ISO-8601 timestamps timestamp: pino.stdTimeFunctions.isoTime, + + // Mask PII (emails, phone numbers) found in log message content — the + // `redact` option above only strips values by known field name, this + // catches PII embedded in free-text messages and arbitrary fields. + hooks: { + logMethod(args, method) { + const maskedArgs = args.map((arg) => maskPii(arg)) as Parameters; + return method.apply(this, maskedArgs); + }, + }, }, transport ? pino.transport(transport) : undefined, ); diff --git a/src/utils/piiMask.ts b/src/utils/piiMask.ts new file mode 100644 index 00000000..90b508cd --- /dev/null +++ b/src/utils/piiMask.ts @@ -0,0 +1,80 @@ +/** + * PII masking utility. + * + * Complements `redact.ts` (which strips values keyed by a sensitive field + * name) by scanning string *content* for common PII patterns — email + * addresses and phone numbers — and masking them wherever they appear, + * including inside free-text messages such as error strings. + */ + +export const PII_MASK = "[REDACTED_PII]"; + +const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; + +// Matches E.164-style numbers (+ followed by 6-15 digits) or grouped/ +// separated numbers like "080-1234-5678" / "(080) 123 4567". Requires a +// separator or a leading "+" so plain numeric ids/amounts are left alone. +const PHONE_REGEX = + /\+\d{6,15}\b|\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}(?:[\s.-]\d{2,4})?\b/g; + +function maskEmail(email: string): string { + const atIndex = email.indexOf("@"); + if (atIndex <= 0) return PII_MASK; + const user = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const visible = user.slice(0, 1); + return `${visible}${"*".repeat(Math.max(user.length - 1, 3))}@${domain}`; +} + +function maskPhone(phone: string): string { + const digits = phone.replace(/\D/g, ""); + if (digits.length <= 4) return "*".repeat(digits.length); + const lastFour = digits.slice(-4); + return `${"*".repeat(digits.length - 4)}${lastFour}`; +} + +/** Masks emails and phone numbers found anywhere within a string. */ +export function maskPiiInString(value: string): string { + return value.replace(EMAIL_REGEX, maskEmail).replace(PHONE_REGEX, maskPhone); +} + +/** + * Recursively walks a value, masking PII found in any string it contains. + * Mirrors `redact()`'s traversal shape but targets value *content* rather + * than field names, so it is safe to compose with `redact()`. + */ +export function maskPii(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (value instanceof Error) { + const plain: Record = { + name: value.name, + message: value.message, + stack: value.stack, + }; + for (const key of Object.keys(value as unknown as Record)) { + plain[key] = (value as unknown as Record)[key]; + } + return maskPii(plain); + } + + if (Array.isArray(value)) { + return value.map((item) => maskPii(item)); + } + + if (typeof value === "object") { + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + result[key] = maskPii(val); + } + return result; + } + + if (typeof value === "string") { + return maskPiiInString(value); + } + + return value; +} From 2960fd9ecff1c80aa16a5d19b36a8e3c33855fe2 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Thu, 30 Jul 2026 04:49:44 +0000 Subject: [PATCH 2/4] feat(compliance): add Prometheus metrics for sanctions list freshness --- src/jobs/sanctionSyncJob.ts | 13 +++++++++++-- src/utils/metrics.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/jobs/sanctionSyncJob.ts b/src/jobs/sanctionSyncJob.ts index 231a75af..49d85d2e 100644 --- a/src/jobs/sanctionSyncJob.ts +++ b/src/jobs/sanctionSyncJob.ts @@ -1,4 +1,9 @@ import { sanctionService } from "../services/sanctionService"; +import { + sanctionsListLastUpdateTimestamp, + sanctionsListRecordCount, + sanctionsSyncFailuresTotal, +} from "../utils/metrics"; /** * Background job to fetch and sync global sanction lists. @@ -6,14 +11,18 @@ import { sanctionService } from "../services/sanctionService"; */ export async function runSanctionSyncJob(): Promise { console.log("[sanction-sync] Starting daily sanction list synchronization..."); - + try { const updates = await sanctionService.fetchSanctionUpdates(); console.log(`[sanction-sync] Fetched ${updates.length} entities from global lists.`); - + await sanctionService.updateSanctionList(updates); console.log("[sanction-sync] Successfully updated internal sanction blacklist."); + + sanctionsListLastUpdateTimestamp.set(Date.now() / 1000); + sanctionsListRecordCount.set(updates.length); } catch (error) { + sanctionsSyncFailuresTotal.inc(); console.error("[sanction-sync] Critical failure during sanction sync:", error); throw error; } diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 5d5cf5f5..179198ed 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -210,6 +210,25 @@ export const crossChainAnomalyTotal = new Counter({ registers: [register], }); +// Sanctions List Sync Metrics +export const sanctionsListLastUpdateTimestamp = new Gauge({ + name: "sanctions_list_last_update_timestamp_seconds", + help: "Unix timestamp (seconds) of the last successful sanctions list sync", + registers: [register], +}); + +export const sanctionsListRecordCount = new Gauge({ + name: "sanctions_list_record_count", + help: "Number of entities in the sanctions list as of the last successful sync", + registers: [register], +}); + +export const sanctionsSyncFailuresTotal = new Counter({ + name: "sanctions_sync_failures_total", + help: "Total number of failed sanctions list sync attempts", + registers: [register], +}); + // System Heartbeat Metric export const systemHeartbeat = new Gauge({ name: "system_heartbeat", From 5b7bc6e998bd79ca5ce102c4a0f10e5738fd02d4 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Thu, 30 Jul 2026 04:52:11 +0000 Subject: [PATCH 3/4] feat(compliance): add monthly Travel Rule audit report with PDF/CSV export --- src/jobs/scheduler.ts | 7 ++ src/jobs/travelRuleAuditReportJob.ts | 30 ++++++ src/reports/travelRuleAuditReport.ts | 154 +++++++++++++++++++++++++++ src/routes/travelRule.ts | 87 +++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/jobs/travelRuleAuditReportJob.ts create mode 100644 src/reports/travelRuleAuditReport.ts diff --git a/src/jobs/scheduler.ts b/src/jobs/scheduler.ts index a6c63cbb..572fb995 100644 --- a/src/jobs/scheduler.ts +++ b/src/jobs/scheduler.ts @@ -26,6 +26,7 @@ import { } from "../config/env"; import { runIndexReindexJob } from "./indexReindexJob"; import { runSanctionSyncJob } from "./sanctionSyncJob"; +import { runTravelRuleAuditReportJob } from "./travelRuleAuditReportJob"; import { startNotificationWorker } from "../workers/notificationWorker"; interface JobConfig { @@ -150,6 +151,12 @@ const JOBS: JobConfig[] = [ schedule: process.env.DATABASE_BACKUP_VERIFY_CRON || "0 3 * * *", handler: runDatabaseBackupVerifyJob, }, + { + name: "travel-rule-audit-report", + // 1st of every month at midnight - summarizes prior month's Travel Rule coverage + schedule: process.env.TRAVEL_RULE_AUDIT_REPORT_CRON || "0 0 1 * *", + handler: runTravelRuleAuditReportJob, + }, ]; async function runJob(job: JobConfig): Promise { diff --git a/src/jobs/travelRuleAuditReportJob.ts b/src/jobs/travelRuleAuditReportJob.ts new file mode 100644 index 00000000..6b6a770b --- /dev/null +++ b/src/jobs/travelRuleAuditReportJob.ts @@ -0,0 +1,30 @@ +import { + generateTravelRuleAuditReport, + previousMonthRange, +} from "../reports/travelRuleAuditReport"; + +/** + * Travel Rule Audit Report Job + * Schedule: 1st of every month at midnight (0 0 1 * *) + * Generates the previous month's Travel Rule coverage summary for + * regulatory review. Full PDF/CSV exports are available on-demand via + * GET /api/v1/compliance/travel-rule/audit-report.{csv,pdf} + */ +export async function runTravelRuleAuditReportJob(): Promise { + const { start, end } = previousMonthRange(new Date()); + const report = await generateTravelRuleAuditReport(start, end); + + console.log( + `[travel-rule-audit] ${start.toISOString().slice(0, 7)}: ` + + `${report.eligibleTransactionCount} eligible, ` + + `${report.capturedRecordCount} captured, ` + + `${report.coveragePercentage}% coverage, ` + + `${report.missedTransactions.length} missed`, + ); + + if (report.missedTransactions.length > 0) { + console.warn( + `[travel-rule-audit] ${report.missedTransactions.length} transaction(s) missing Travel Rule data`, + ); + } +} diff --git a/src/reports/travelRuleAuditReport.ts b/src/reports/travelRuleAuditReport.ts new file mode 100644 index 00000000..6116f5db --- /dev/null +++ b/src/reports/travelRuleAuditReport.ts @@ -0,0 +1,154 @@ +/** + * Travel Rule Compliance Audit Report — FATF Recommendation 16 + * + * Compares transactions eligible for Travel Rule data collection + * (deposits >= TRAVEL_RULE_THRESHOLD_USD) against captured + * `travel_rule_records` for a given period, to surface coverage gaps + * for regulatory review. + */ + +import PDFDocument from "pdfkit"; +import { pool } from "../config/database"; +import { TRAVEL_RULE_THRESHOLD_USD } from "../compliance/travelRule"; + +export interface TravelRuleAuditReport { + periodStart: string; + periodEnd: string; + eligibleTransactionCount: number; + capturedRecordCount: number; + coveragePercentage: number; + missedTransactions: Array<{ + transactionId: string; + amount: number; + createdAt: string; + }>; + // No automated remediation-tracking exists yet — compliance officers + // populate this manually after following up on `missedTransactions`. + correctiveActionsTaken: string[]; + generatedAt: string; +} + +/** Generates a Travel Rule coverage report for [periodStart, periodEnd). */ +export async function generateTravelRuleAuditReport( + periodStart: Date, + periodEnd: Date, +): Promise { + const result = await pool.query<{ + id: string; + amount: string; + created_at: Date; + captured: boolean; + }>( + `SELECT t.id, t.amount, t.created_at, (r.id IS NOT NULL) AS captured + FROM transactions t + LEFT JOIN travel_rule_records r ON r.transaction_id = t.id + WHERE t.type = 'deposit' + AND t.status = 'completed' + AND t.amount >= $1 + AND t.created_at >= $2 + AND t.created_at < $3 + ORDER BY t.created_at ASC`, + [TRAVEL_RULE_THRESHOLD_USD, periodStart, periodEnd], + ); + + const eligible = result.rows; + const missed = eligible.filter((row) => !row.captured); + const capturedCount = eligible.length - missed.length; + const coveragePercentage = + eligible.length === 0 ? 100 : (capturedCount / eligible.length) * 100; + + return { + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + eligibleTransactionCount: eligible.length, + capturedRecordCount: capturedCount, + coveragePercentage: Math.round(coveragePercentage * 100) / 100, + missedTransactions: missed.map((row) => ({ + transactionId: row.id, + amount: Number(row.amount), + createdAt: row.created_at.toISOString(), + })), + correctiveActionsTaken: [], + generatedAt: new Date().toISOString(), + }; +} + +/** Serializes a report to CSV — summary header followed by missed transactions. */ +export function travelRuleAuditReportToCsv(report: TravelRuleAuditReport): string { + const lines = [ + "# Travel Rule Compliance Audit Report", + `# Period,${report.periodStart},${report.periodEnd}`, + `# EligibleTransactions,${report.eligibleTransactionCount}`, + `# CapturedRecords,${report.capturedRecordCount}`, + `# CoveragePercentage,${report.coveragePercentage}`, + `# GeneratedAt,${report.generatedAt}`, + "", + "transactionId,amount,createdAt", + ...report.missedTransactions.map( + (t) => `${t.transactionId},${t.amount},${t.createdAt}`, + ), + ]; + return lines.join("\n"); +} + +/** Renders a report as a PDF buffer for regulatory review/export. */ +export async function travelRuleAuditReportToPdf( + report: TravelRuleAuditReport, +): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: "A4", margin: 50 }); + const chunks: Buffer[] = []; + + doc.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", (err) => reject(err)); + + doc.fillColor("#2c3e50").fontSize(18).text("Travel Rule Compliance Audit Report", { + align: "center", + }); + doc.moveDown(1); + + doc.fillColor("#34495e").fontSize(12).text("Summary", { underline: true }); + doc.moveDown(0.5); + doc.fontSize(10).fillColor("#000"); + doc.text(`Period: ${report.periodStart} to ${report.periodEnd}`); + doc.text(`Eligible Transactions: ${report.eligibleTransactionCount}`); + doc.text(`Captured Records: ${report.capturedRecordCount}`); + doc.text(`Coverage: ${report.coveragePercentage}%`); + doc.text(`Generated At: ${report.generatedAt}`); + + doc.moveDown(1); + doc.fillColor("#34495e").fontSize(12).text("Missed Transactions", { underline: true }); + doc.moveDown(0.5); + doc.fontSize(10).fillColor("#000"); + + if (report.missedTransactions.length === 0) { + doc.text("None — full coverage for this period."); + } else { + for (const t of report.missedTransactions) { + doc.text(`${t.transactionId} | ${t.amount} | ${t.createdAt}`); + } + } + + doc.moveDown(1); + doc.fillColor("#34495e").fontSize(12).text("Corrective Actions Taken", { underline: true }); + doc.moveDown(0.5); + doc.fontSize(10).fillColor("#000"); + doc.text( + report.correctiveActionsTaken.length === 0 + ? "None recorded." + : report.correctiveActionsTaken.join("\n"), + ); + + doc.end(); + }); +} + +/** Returns the [start, end) bounds of the calendar month prior to `reference`. */ +export function previousMonthRange(reference: Date): { start: Date; end: Date } { + const start = new Date( + Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth() - 1, 1), + ); + const end = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1)); + return { start, end }; +} diff --git a/src/routes/travelRule.ts b/src/routes/travelRule.ts index 6074885e..b0404ac0 100644 --- a/src/routes/travelRule.ts +++ b/src/routes/travelRule.ts @@ -9,6 +9,12 @@ import { travelRuleService, TravelRuleRecord } from "../compliance/travelRule"; import { createError } from "../middleware/errorHandler"; import { ERROR_CODES } from "../constants/errorCodes"; import { travelRuleCheckHandler } from "../controllers/complianceController"; +import { + generateTravelRuleAuditReport, + travelRuleAuditReportToCsv, + travelRuleAuditReportToPdf, + previousMonthRange, +} from "../reports/travelRuleAuditReport"; export const travelRuleRoutes = Router(); @@ -18,6 +24,18 @@ function parseOptionalDate(value: unknown): Date | undefined { return isNaN(d.getTime()) ? undefined : d; } +/** Parses a "YYYY-MM" query param into a [start, end) month range in UTC. */ +function resolveReportMonthRange(month: unknown): { start: Date; end: Date } { + if (typeof month === "string" && /^\d{4}-\d{2}$/.test(month)) { + const [year, mon] = month.split("-").map(Number); + return { + start: new Date(Date.UTC(year, mon - 1, 1)), + end: new Date(Date.UTC(year, mon, 1)), + }; + } + return previousMonthRange(new Date()); +} + function requireAdmin(req: Request, res: Response): boolean { const user = (req as AuthRequest).user; if (!user || user.role !== "admin") { @@ -151,6 +169,75 @@ travelRuleRoutes.get("/export.csv", requireAuth, async (req: Request, res: Respo } }); +/** + * GET /api/v1/compliance/travel-rule/audit-report + * Query params: month (YYYY-MM, defaults to previous calendar month) + * Returns coverage %, missed transactions, and corrective actions taken. + */ +travelRuleRoutes.get("/audit-report", requireAuth, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + + try { + const { start, end } = resolveReportMonthRange(req.query.month); + const report = await generateTravelRuleAuditReport(start, end); + res.json(report); + } catch (err) { + console.error("[travel-rule] audit report error:", err instanceof Error ? err.message : err); + throw createError(ERROR_CODES.INTERNAL_ERROR, "Audit report generation failed", { + error: "Audit report generation failed", + }); + } +}); + +/** + * GET /api/v1/compliance/travel-rule/audit-report.csv + */ +travelRuleRoutes.get("/audit-report.csv", requireAuth, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + + try { + const { start, end } = resolveReportMonthRange(req.query.month); + const report = await generateTravelRuleAuditReport(start, end); + const filename = `travel-rule-audit-${start.toISOString().slice(0, 7)}.csv`; + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(travelRuleAuditReportToCsv(report)); + } catch (err) { + console.error( + "[travel-rule] audit report csv error:", + err instanceof Error ? err.message : err, + ); + throw createError(ERROR_CODES.INTERNAL_ERROR, "Audit report CSV export failed", { + error: "Audit report CSV export failed", + }); + } +}); + +/** + * GET /api/v1/compliance/travel-rule/audit-report.pdf + */ +travelRuleRoutes.get("/audit-report.pdf", requireAuth, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + + try { + const { start, end } = resolveReportMonthRange(req.query.month); + const report = await generateTravelRuleAuditReport(start, end); + const filename = `travel-rule-audit-${start.toISOString().slice(0, 7)}.pdf`; + const pdfBuffer = await travelRuleAuditReportToPdf(report); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(pdfBuffer); + } catch (err) { + console.error( + "[travel-rule] audit report pdf error:", + err instanceof Error ? err.message : err, + ); + throw createError(ERROR_CODES.INTERNAL_ERROR, "Audit report PDF export failed", { + error: "Audit report PDF export failed", + }); + } +}); + /** * GET /api/v1/compliance/travel-rule/:transactionId * Fetch a single Travel Rule record by transaction ID. From e5167e860ba83a3ef1dc9f686b466fb365043747 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Thu, 30 Jul 2026 04:54:44 +0000 Subject: [PATCH 4/4] feat(auth): wire device fingerprinting into login with step-up on repeated mismatches --- src/middleware/fingerprint.ts | 122 +++++++++++++++++++++++++--------- src/routes/auth.ts | 16 +++-- 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/src/middleware/fingerprint.ts b/src/middleware/fingerprint.ts index e0ed408f..e733e566 100644 --- a/src/middleware/fingerprint.ts +++ b/src/middleware/fingerprint.ts @@ -1,6 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { pool } from "../config/database"; +import { redisClient } from "../config/redis"; import { createHash } from "crypto"; +import { getCurrentRequestIp } from "../services/loginAnomaly"; declare module "express-serve-static-core" { interface Request { @@ -8,31 +10,103 @@ declare module "express-serve-static-core" { } } +/** Number of new-device fingerprints within the window before step-up auth is required. */ +const MISMATCH_STEP_UP_THRESHOLD = 3; +const MISMATCH_WINDOW_SECONDS = 24 * 60 * 60; + export function hashString(value: string | null | undefined): string { const v = value ?? ""; return createHash("sha256").update(v, "utf8").digest("hex"); } +function headerValue(req: Request, name: string): string { + const value = req.headers[name]; + return (Array.isArray(value) ? value[0] : value) ?? ""; +} + +/** Extracts the negotiated TLS cipher name, or "" for a plaintext connection. */ +function extractTlsCipher(req: Request): string { + const socket = req.socket as unknown as { getCipher?: () => { name: string } | undefined }; + return socket.getCipher?.()?.name ?? ""; +} + // Utility to extract fingerprint from headers/params and return a hashed value export function extractFingerprint(req: Request): string { - 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 userAgent = headerValue(req, "user-agent"); + const acceptLanguage = headerValue(req, "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}`; + headerValue(req, "x-device-id") || (req.query?.deviceId as string) || ""; + const ipAddress = getCurrentRequestIp(req) ?? ""; + const tlsCipher = extractTlsCipher(req); + + // Hash the combined fingerprint parts to avoid storing raw UA / IP / language + const raw = `${userAgent}|${acceptLanguage}|${deviceId}|${ipAddress}|${tlsCipher}`; return hashString(raw); } +export interface DeviceFingerprintCheck { + fingerprint: string; + isNewDevice: boolean; + /** True once repeated new-device mismatches within the window exceed the threshold. */ + requiresStepUp: boolean; +} + +/** + * Records the device fingerprint for a login/authenticated request and + * reports whether this is a device the user hasn't used before. + * + * Works across login sessions — every call for a given user is checked + * against all previously seen fingerprints for that user, not just the + * current session. Repeated new-device mismatches within a 24h window + * flag `requiresStepUp` so the caller can demand additional verification + * (e.g. 2FA) before completing authentication. + */ +export async function recordDeviceFingerprint( + userId: string, + req: Request, +): Promise { + const fingerprint = extractFingerprint(req); + + const existing = await pool.query( + "SELECT id FROM device_fingerprints WHERE user_id = $1 AND fingerprint = $2", + [userId, fingerprint], + ); + + const isNewDevice = existing.rows.length === 0; + + if (!isNewDevice) { + return { fingerprint, isNewDevice: false, requiresStepUp: false }; + } + + await pool.query( + "INSERT INTO device_fingerprints (user_id, fingerprint) VALUES ($1, $2)", + [userId, fingerprint], + ); + + console.warn( + JSON.stringify({ + event: "device_fingerprint_changed", + userId, + fingerprint, + timestamp: new Date().toISOString(), + }), + ); + + let requiresStepUp = false; + try { + const mismatchKey = `fingerprint:mismatches:${userId}`; + const count = await redisClient.incr(mismatchKey); + if (count === 1) { + await redisClient.expire(mismatchKey, MISMATCH_WINDOW_SECONDS); + } + requiresStepUp = count >= MISMATCH_STEP_UP_THRESHOLD; + } catch (error) { + console.error("[fingerprint] Failed to track mismatch count:", error); + } + + return { fingerprint, isNewDevice: true, requiresStepUp }; +} + // Middleware to collect and compare device fingerprints export async function fingerprintMiddleware( req: Request, @@ -41,24 +115,8 @@ export async function fingerprintMiddleware( ) { 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 (store hashed fingerprint) - const result = await pool.query( - "SELECT * FROM device_fingerprints WHERE user_id = $1 AND fingerprint = $2", - [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], - ); - // TODO: Trigger email alert to user - req.isNewDevice = true; - } else { - req.isNewDevice = false; - } + const result = await recordDeviceFingerprint(userId, req); + req.isNewDevice = result.isNewDevice; next(); } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b0f9f97a..d4432241 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -22,6 +22,7 @@ import { import { getLockoutStatus, recordFailedAttempt } from "../auth/lockout"; import { verifyTOTPToken, verifyBackupCode, is2FAEnabled } from "../auth/2fa"; import { evaluateAdminLoginAnomaly } from "../services/loginAnomaly"; +import { recordDeviceFingerprint } from "../middleware/fingerprint"; import { validateRequest } from "../middleware/validation"; import { hashPassword } from "../utils/password"; import { redisClient } from "../config/redis"; @@ -167,16 +168,19 @@ authRoutes.post( } const anomaly = await evaluateAdminLoginAnomaly(req, user); + const fingerprintCheck = await recordDeviceFingerprint(user.id, req); + + if (anomaly.suspicious || fingerprintCheck.requiresStepUp) { + const reason = anomaly.reason ?? "repeated_device_fingerprint_mismatch"; - if (anomaly.suspicious) { if (!is2FAEnabled(user)) { throw createError( ERROR_CODES.FORBIDDEN, - "Anomalous admin login was blocked. Enable two-factor authentication and retry.", + "Anomalous login was blocked. Enable two-factor authentication and retry.", { - error: "Suspicious admin login detected", + error: "Suspicious login detected", requiresTwoFactor: true, - anomaly: anomaly.reason, + anomaly: reason, }, ); } @@ -208,11 +212,11 @@ authRoutes.post( if (!verified2fa) { throw createError( ERROR_CODES.FORBIDDEN, - "Suspicious admin login detected. Provide X-2FA-Token header or backupCode to continue.", + "Suspicious login detected. Provide X-2FA-Token header or backupCode to continue.", { error: "Two-factor authentication required", requiresTwoFactor: true, - anomaly: anomaly.reason, + anomaly: reason, }, ); }