Files
publisher-dashboard/apps/api-server/src/utils/crypto.ts
RevIQ 575ca83300 Add GeoIP lookup support and update device fingerprints to base58
- Add maxmind library for GeoIP database lookups when not behind Cloudflare
- Extract client IP from multiple header sources (CF, X-Real-IP, X-Forwarded-For, etc.)
- Change device fingerprints from UUID to base58 with device_ prefix
- Add isValidDeviceFingerprint() that accepts both new and legacy formats
- Colocate unit tests with source files, remove __tests__/unit directory
- Add test coverage reporting to test script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:41:21 +08:00

105 lines
2.7 KiB
TypeScript

import { generateSecureBase58Token } from "@reviq/utils";
import { base58 } from "@scure/base";
// Re-export for convenience
export { generateSecureBase58Token };
/**
* Token prefix for all RevIQ API tokens
*/
export const TOKEN_PREFIX = "reviq_";
/**
* Hash a token with SHA-256 for storage in database
* Never store raw tokens - always hash first
* Uses Web Crypto API for Cloudflare Workers compatibility
*/
export const hashToken = async (token: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(token);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = new Uint8Array(hashBuffer);
return Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**
* Validate that a token has the correct format
* Returns the raw bytes if valid, null if invalid
*/
export const parseToken = (token: string): Uint8Array | null => {
if (!token.startsWith(TOKEN_PREFIX)) {
return null;
}
const encoded = token.slice(TOKEN_PREFIX.length);
try {
const bytes = base58.decode(encoded);
// Expect 32 bytes of entropy
if (bytes.length !== 32) {
return null;
}
return bytes;
} catch {
return null;
}
};
/**
* Check if a token has the valid reviq_ prefix format
*/
export const isValidTokenFormat = (token: string): boolean => {
return parseToken(token) !== null;
};
/**
* Generate a session token (UUID v4)
*/
export const generateSessionToken = (): string => {
return crypto.randomUUID();
};
/**
* Device fingerprint prefix for new fingerprints
*/
export const DEVICE_FINGERPRINT_PREFIX = "device_";
/**
* Generate a device fingerprint (base58 with device_ prefix)
*/
export const generateDeviceFingerprint = (): string => {
return generateSecureBase58Token(DEVICE_FINGERPRINT_PREFIX);
};
/**
* Check if a string is a valid device fingerprint.
* Accepts both new format (device_ prefix) and legacy UUIDs.
*/
export const isValidDeviceFingerprint = (fingerprint: string): boolean => {
// New format: device_ prefix with base58
if (fingerprint.startsWith(DEVICE_FINGERPRINT_PREFIX)) {
const base58Part = fingerprint.slice(DEVICE_FINGERPRINT_PREFIX.length);
if (base58Part.length === 0) {
return false;
}
try {
base58.decode(base58Part);
return true;
} catch {
return false;
}
}
// Legacy format: UUID v4
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(fingerprint);
};
/**
* Generate expiration date
*/
export const generateExpiry = (seconds: number): Date => {
return new Date(Date.now() + seconds * 1000);
};