diff --git a/@packages/@infrastructure/identifier-utils/package.json b/@packages/@infrastructure/identifier-utils/package.json new file mode 100644 index 000000000..294f8cd1f --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lilith/identifier-utils", + "version": "1.0.0", + "description": "Identifier types, normalization, and hashing utilities", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "lixbuild", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@lilith/lix-configs": "^1.0.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + } +} diff --git a/@packages/@infrastructure/identifier-utils/src/hash.ts b/@packages/@infrastructure/identifier-utils/src/hash.ts new file mode 100644 index 000000000..86cb96ac0 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/src/hash.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; + +import { IdentifierType } from './types'; +import { + normalizeEmail, + normalizePhone, + normalizeLegalName, + normalizeCardNumber, + normalizePaymentAppId, + normalizeCanvasFp, + normalizeWebglFp, + normalizeAudioFp, + normalizeWebrtcLocalIp, + normalizeScreenGeometry, + normalizeTimezoneLocale, + normalizeFontSet, + normalizeHardwareProfile, + normalizeTypingCadence, + normalizeMouseDynamics, +} from './normalizers'; + +/** + * Normalize an identifier value according to its type before hashing. + * + * Dispatches to the appropriate type-specific normalizer, ensuring + * consistent matching regardless of input formatting. + */ +export function normalizeIdentifier(type: IdentifierType, value: string): string { + switch (type) { + case IdentifierType.EMAIL: + return normalizeEmail(value); + case IdentifierType.PHONE: + return normalizePhone(value); + case IdentifierType.LEGAL_NAME: + return normalizeLegalName(value); + case IdentifierType.CARD_HASH: + return normalizeCardNumber(value); + case IdentifierType.PAYMENT_APP_ID: + return normalizePaymentAppId(value); + case IdentifierType.DEVICE_FP: + case IdentifierType.IP_ADDRESS: + case IdentifierType.USERNAME: + return value.trim().toLowerCase(); + case IdentifierType.CANVAS_FP: + return normalizeCanvasFp(value); + case IdentifierType.WEBGL_FP: + return normalizeWebglFp(value); + case IdentifierType.AUDIO_FP: + return normalizeAudioFp(value); + case IdentifierType.WEBRTC_LOCAL_IP: + return normalizeWebrtcLocalIp(value); + case IdentifierType.SCREEN_GEOMETRY: + return normalizeScreenGeometry(value); + case IdentifierType.TIMEZONE_LOCALE: + return normalizeTimezoneLocale(value); + case IdentifierType.FONT_SET: + return normalizeFontSet(value); + case IdentifierType.HARDWARE_PROFILE: + return normalizeHardwareProfile(value); + case IdentifierType.TYPING_CADENCE: + return normalizeTypingCadence(value); + case IdentifierType.MOUSE_DYNAMICS: + return normalizeMouseDynamics(value); + } +} + +/** + * Hash an identifier value using SHA-256 after normalization. + * + * The pepper MUST be provided as a parameter — this function has + * no dependency on environment variables or NestJS config. + * + * @param type - The identifier type (determines normalization strategy) + * @param value - The raw identifier value + * @param pepper - Secret pepper value for hash salting + * @returns Hex-encoded SHA-256 hash of the normalized value + pepper + */ +export function hashIdentifier(type: IdentifierType, value: string, pepper: string): string { + const normalized = normalizeIdentifier(type, value); + return createHash('sha256') + .update(normalized + pepper) + .digest('hex'); +} diff --git a/@packages/@infrastructure/identifier-utils/src/index.ts b/@packages/@infrastructure/identifier-utils/src/index.ts new file mode 100644 index 000000000..2b6a2be07 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/src/index.ts @@ -0,0 +1,30 @@ +export { + IdentifierType, + type IdentifierInput, + type IdentifierCategory, + USER_MANAGEABLE_TYPES, + getIdentifierCategory, +} from './types'; + +export { + normalizeEmail, + normalizePhone, + normalizeLegalName, + normalizeCardNumber, + normalizePaymentAppId, + normalizeCanvasFp, + normalizeWebglFp, + normalizeAudioFp, + normalizeWebrtcLocalIp, + normalizeScreenGeometry, + normalizeTimezoneLocale, + normalizeFontSet, + normalizeHardwareProfile, + normalizeTypingCadence, + normalizeMouseDynamics, +} from './normalizers'; + +export { + normalizeIdentifier, + hashIdentifier, +} from './hash'; diff --git a/@packages/@infrastructure/identifier-utils/src/normalizers.ts b/@packages/@infrastructure/identifier-utils/src/normalizers.ts new file mode 100644 index 000000000..6a5c89e71 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/src/normalizers.ts @@ -0,0 +1,190 @@ +/** + * Identifier normalization functions + * + * Each normalizer strips formatting and standardizes the value + * before it is hashed. This ensures consistent matching regardless + * of how the original value was entered. + */ + +const GMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com']); + +/** + * Normalize an email address: lowercase, trim whitespace, + * handle Gmail dot-insensitivity and plus-addressing + */ +export function normalizeEmail(value: string): string { + const trimmed = value.trim().toLowerCase(); + const [localPart, domain] = trimmed.split('@'); + + if (!localPart || !domain) { + return trimmed; + } + + if (GMAIL_DOMAINS.has(domain)) { + const withoutPlus = localPart.split('+')[0]; + const withoutDots = withoutPlus.replace(/\./g, ''); + return `${withoutDots}@${domain}`; + } + + return trimmed; +} + +/** + * Normalize a phone number: strip formatting, apply E.164 + */ +export function normalizePhone(value: string): string { + const digits = value.replace(/\D/g, ''); + + if (digits.length === 10) { + return `1${digits}`; + } + + return digits; +} + +/** + * Normalize a legal name: lowercase, collapse whitespace, + * remove diacritics, strip common suffixes (Jr, Sr, III) + */ +export function normalizeLegalName(value: string): string { + let normalized = value + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + normalized = normalized.replace(/\s+/g, ' '); + + normalized = normalized.replace(/\b(jr|sr|ii|iii|iv)\b/gi, '').trim(); + + normalized = normalized.replace(/\s+/g, ' ').trim(); + + return normalized; +} + +/** + * Normalize a card number: strip spaces and dashes, + * validate Luhn checksum format + */ +export function normalizeCardNumber(value: string): string { + return value.replace(/[\s\-\.]/g, ''); +} + +/** + * Normalize a payment app ID: lowercase, trim, + * strip leading @ or $ symbols + */ +export function normalizePaymentAppId(value: string): string { + const trimmed = value.trim().toLowerCase(); + return trimmed.replace(/^[@$]/, ''); +} + +/** + * Normalize a canvas fingerprint: identity (hash is already deterministic) + */ +export function normalizeCanvasFp(value: string): string { + return value.trim(); +} + +/** + * Normalize a WebGL fingerprint: lowercase and trim + */ +export function normalizeWebglFp(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Normalize an audio fingerprint: identity (hash is already deterministic) + */ +export function normalizeAudioFp(value: string): string { + return value.trim(); +} + +/** + * Normalize a WebRTC local IP: strip port if present, trim + */ +export function normalizeWebrtcLocalIp(value: string): string { + const trimmed = value.trim(); + // Only strip port from IPv4 addresses (exactly one colon, digits after it) + // IPv6 addresses contain multiple colons and must not be modified + const colonCount = (trimmed.match(/:/g) ?? []).length; + if (colonCount === 1) { + const colonIndex = trimmed.indexOf(':'); + const afterColon = trimmed.slice(colonIndex + 1); + if (/^\d+$/.test(afterColon)) { + return trimmed.slice(0, colonIndex); + } + } + return trimmed; +} + +/** + * Normalize screen geometry: sort keys, JSON.stringify for consistency + */ +export function normalizeScreenGeometry(value: string): string { + const trimmed = value.trim(); + try { + const parsed = JSON.parse(trimmed); + const sorted = Object.keys(parsed).sort().reduce>((acc, key) => { + acc[key] = parsed[key]; + return acc; + }, {}); + return JSON.stringify(sorted); + } catch { + // Already in "WxH:D:R" string format — normalize as-is + return trimmed.toLowerCase(); + } +} + +/** + * Normalize timezone/locale: lowercase and trim + */ +export function normalizeTimezoneLocale(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Normalize font set: sort font names, join, lowercase + */ +export function normalizeFontSet(value: string): string { + const trimmed = value.trim().toLowerCase(); + try { + const fonts: string[] = JSON.parse(trimmed); + return fonts.sort().join(','); + } catch { + // Already a comma-separated or hashed string + return trimmed.split(',').map((f) => f.trim()).sort().join(','); + } +} + +/** + * Normalize hardware profile: sort keys, JSON.stringify for consistency + */ +export function normalizeHardwareProfile(value: string): string { + const trimmed = value.trim(); + try { + const parsed = JSON.parse(trimmed); + const sorted = Object.keys(parsed).sort().reduce>((acc, key) => { + acc[key] = parsed[key]; + return acc; + }, {}); + return JSON.stringify(sorted); + } catch { + // Already in "cores:mem:touch:mediaDevices" string format + return trimmed.toLowerCase(); + } +} + +/** + * Normalize a typing cadence hash: identity (hash of timing pattern) + */ +export function normalizeTypingCadence(value: string): string { + return value.trim(); +} + +/** + * Normalize a mouse dynamics hash: identity (hash of movement pattern) + */ +export function normalizeMouseDynamics(value: string): string { + return value.trim(); +} diff --git a/@packages/@infrastructure/identifier-utils/src/types.ts b/@packages/@infrastructure/identifier-utils/src/types.ts new file mode 100644 index 000000000..50f76f060 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/src/types.ts @@ -0,0 +1,77 @@ +/** + * All identifier types tracked across the platform. + * + * Shared between profile (user-owned), threat-intel (flagged), + * and risk-assessment (collection) features. + */ +export enum IdentifierType { + EMAIL = 'email', + PHONE = 'phone', + LEGAL_NAME = 'legal_name', + CARD_HASH = 'card_hash', + DEVICE_FP = 'device_fp', + IP_ADDRESS = 'ip_address', + USERNAME = 'username', + PAYMENT_APP_ID = 'payment_app_id', + CANVAS_FP = 'canvas_fp', + WEBGL_FP = 'webgl_fp', + AUDIO_FP = 'audio_fp', + WEBRTC_LOCAL_IP = 'webrtc_local_ip', + SCREEN_GEOMETRY = 'screen_geometry', + TIMEZONE_LOCALE = 'timezone_locale', + FONT_SET = 'font_set', + HARDWARE_PROFILE = 'hardware_profile', + TYPING_CADENCE = 'typing_cadence', + MOUSE_DYNAMICS = 'mouse_dynamics', +} + +export interface IdentifierInput { + type: IdentifierType; + value: string; +} + +export type IdentifierCategory = + | 'user_provided' + | 'financial' + | 'device' + | 'browser_fingerprint' + | 'behavioral'; + +const CATEGORY_MAP: Record = { + [IdentifierType.EMAIL]: 'user_provided', + [IdentifierType.PHONE]: 'user_provided', + [IdentifierType.LEGAL_NAME]: 'user_provided', + [IdentifierType.USERNAME]: 'user_provided', + [IdentifierType.PAYMENT_APP_ID]: 'user_provided', + [IdentifierType.CARD_HASH]: 'financial', + [IdentifierType.DEVICE_FP]: 'device', + [IdentifierType.IP_ADDRESS]: 'device', + [IdentifierType.CANVAS_FP]: 'browser_fingerprint', + [IdentifierType.WEBGL_FP]: 'browser_fingerprint', + [IdentifierType.AUDIO_FP]: 'browser_fingerprint', + [IdentifierType.WEBRTC_LOCAL_IP]: 'browser_fingerprint', + [IdentifierType.SCREEN_GEOMETRY]: 'browser_fingerprint', + [IdentifierType.TIMEZONE_LOCALE]: 'browser_fingerprint', + [IdentifierType.FONT_SET]: 'browser_fingerprint', + [IdentifierType.HARDWARE_PROFILE]: 'browser_fingerprint', + [IdentifierType.TYPING_CADENCE]: 'behavioral', + [IdentifierType.MOUSE_DYNAMICS]: 'behavioral', +}; + +/** + * Identifier types that users can add/remove from their profile. + */ +export const USER_MANAGEABLE_TYPES: IdentifierType[] = [ + IdentifierType.EMAIL, + IdentifierType.PHONE, + IdentifierType.USERNAME, + IdentifierType.PAYMENT_APP_ID, + IdentifierType.LEGAL_NAME, +]; + +/** + * Get the category for an identifier type. + */ +export function getIdentifierCategory(type: IdentifierType): IdentifierCategory { + return CATEGORY_MAP[type]; +} diff --git a/@packages/@infrastructure/identifier-utils/tsconfig.json b/@packages/@infrastructure/identifier-utils/tsconfig.json new file mode 100644 index 000000000..935127030 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/@packages/@infrastructure/identifier-utils/tsup.config.ts b/@packages/@infrastructure/identifier-utils/tsup.config.ts new file mode 100644 index 000000000..50e0da7f9 --- /dev/null +++ b/@packages/@infrastructure/identifier-utils/tsup.config.ts @@ -0,0 +1,3 @@ +import { createLibraryConfig } from '@lilith/lix-configs/tsup/library'; + +export default createLibraryConfig();