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:
Lilith 2026-02-15 05:14:43 -08:00
parent f33a707129
commit adef4ffb3e
7 changed files with 427 additions and 0 deletions

View 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"
}
}

View 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');
}

View 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';

View 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();
}

View 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];
}

View 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"]
}

View file

@ -0,0 +1,3 @@
import { createLibraryConfig } from '@lilith/lix-configs/tsup/library';
export default createLibraryConfig();