chore(infrastructure): 🔧 Refactor identifier generation/validation utilities for UUID handling, ensuring system-wide ID consistency
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f33a707129
commit
adef4ffb3e
7 changed files with 427 additions and 0 deletions
24
@packages/@infrastructure/identifier-utils/package.json
Normal file
24
@packages/@infrastructure/identifier-utils/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
83
@packages/@infrastructure/identifier-utils/src/hash.ts
Normal file
83
@packages/@infrastructure/identifier-utils/src/hash.ts
Normal file
|
|
@ -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');
|
||||
}
|
||||
30
@packages/@infrastructure/identifier-utils/src/index.ts
Normal file
30
@packages/@infrastructure/identifier-utils/src/index.ts
Normal file
|
|
@ -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';
|
||||
190
@packages/@infrastructure/identifier-utils/src/normalizers.ts
Normal file
190
@packages/@infrastructure/identifier-utils/src/normalizers.ts
Normal file
|
|
@ -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<Record<string, unknown>>((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<Record<string, unknown>>((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();
|
||||
}
|
||||
77
@packages/@infrastructure/identifier-utils/src/types.ts
Normal file
77
@packages/@infrastructure/identifier-utils/src/types.ts
Normal file
|
|
@ -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, IdentifierCategory> = {
|
||||
[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];
|
||||
}
|
||||
20
@packages/@infrastructure/identifier-utils/tsconfig.json
Normal file
20
@packages/@infrastructure/identifier-utils/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { createLibraryConfig } from '@lilith/lix-configs/tsup/library';
|
||||
|
||||
export default createLibraryConfig();
|
||||
Loading…
Add table
Reference in a new issue