Skip to content

Browser Fingerprint Viewer — TypeScript source

See exactly what websites can learn about your browser without cookies — screen, GPU, fonts, timezone, language, and more. Educational, not tracking.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Browser Fingerprint Viewer — signal collection + privacy-risk analysis.
//
// Split in two layers:
//   - Pure analysis helpers (SIGNAL_DEFINITIONS, classifySignalRisk,
//     buildSignal, groupByCategory, countByRisk, concatSignalValues,
//     hashFingerprint) — no DOM, fully unit-tested.
//   - collectFingerprint() — reads real browser APIs (navigator, screen,
//     canvas, WebGL, Intl). Exercised implicitly by the island; every probe
//     is guarded so a blocked API degrades to "not available" instead of
//     throwing.

export type RiskLevel = 'low' | 'medium' | 'high';

export type FingerprintCategory = 'Hardware' | 'Graphics' | 'Network' | 'Browser' | 'Input';

export interface FingerprintSignal {
  id: string;
  label: string;
  value: string;
  risk: RiskLevel;
  category: FingerprintCategory;
}

/** Canonical category order (card grid, top to bottom). */
export const FINGERPRINT_CATEGORIES: readonly FingerprintCategory[] = [
  'Hardware',
  'Graphics',
  'Network',
  'Browser',
  'Input',
];

interface SignalDef {
  label: string;
  risk: RiskLevel;
  category: FingerprintCategory;
}

/**
 * Static registry of every signal this tool collects, with its privacy-risk
 * classification. Single source of truth: buildSignal() and the tests derive
 * from it, so a signal can never be collected without being classified.
 *
 * Risk rubric:
 *   high   — near-unique on its own or part of a rare combination
 *   medium — narrows you to a sizable-but-specific population
 *   low    — coarse; shared by millions of browsers
 */
export const SIGNAL_DEFINITIONS: Readonly<Record<string, SignalDef>> = {
  // High risk
  canvas: { label: 'Canvas fingerprint', risk: 'high', category: 'Graphics' },
  'webgl-renderer': { label: 'WebGL renderer', risk: 'high', category: 'Graphics' },
  'webgl-vendor': { label: 'WebGL vendor', risk: 'high', category: 'Graphics' },
  fonts: { label: 'Installed fonts', risk: 'high', category: 'Browser' },
  timezone: { label: 'Timezone', risk: 'high', category: 'Browser' },
  // Medium risk
  screen: { label: 'Screen & color depth', risk: 'medium', category: 'Hardware' },
  'device-pixel-ratio': { label: 'Device pixel ratio', risk: 'medium', category: 'Hardware' },
  'hardware-concurrency': { label: 'CPU cores', risk: 'medium', category: 'Hardware' },
  'device-memory': { label: 'Device memory', risk: 'medium', category: 'Hardware' },
  platform: { label: 'Platform', risk: 'medium', category: 'Browser' },
  languages: { label: 'Languages', risk: 'medium', category: 'Browser' },
  touch: { label: 'Touch support', risk: 'medium', category: 'Input' },
  connection: { label: 'Connection type', risk: 'medium', category: 'Network' },
  // Low risk
  'user-agent': { label: 'User agent', risk: 'low', category: 'Browser' },
  'do-not-track': { label: 'Do Not Track', risk: 'low', category: 'Browser' },
  'cookies-enabled': { label: 'Cookies enabled', risk: 'low', category: 'Browser' },
  online: { label: 'Online status', risk: 'low', category: 'Network' },
  'pdf-viewer': { label: 'PDF viewer', risk: 'low', category: 'Browser' },
};

/** Risk level for a known signal id; undefined for unknown ids. */
export function classifySignalRisk(id: string): RiskLevel | undefined {
  return SIGNAL_DEFINITIONS[id]?.risk;
}

/**
 * Build a signal from its id and collected value. Throws on unknown ids so a
 * typo'd id fails loudly instead of silently rendering an unclassified row.
 */
export function buildSignal(id: string, value: string): FingerprintSignal {
  const def = SIGNAL_DEFINITIONS[id];
  if (!def) throw new Error(`Unknown fingerprint signal id: ${id}`);
  return { id, label: def.label, value, risk: def.risk, category: def.category };
}

export interface CategoryGroup {
  category: FingerprintCategory;
  signals: FingerprintSignal[];
}

/** Group signals by category in canonical order, omitting empty categories. */
export function groupByCategory(signals: FingerprintSignal[]): CategoryGroup[] {
  const groups = new Map<FingerprintCategory, FingerprintSignal[]>();
  for (const signal of signals) {
    const list = groups.get(signal.category);
    if (list) list.push(signal);
    else groups.set(signal.category, [signal]);
  }
  return FINGERPRINT_CATEGORIES.filter((c) => groups.has(c)).map((category) => ({
    category,
    signals: groups.get(category)!,
  }));
}

/** Count signals per risk level (drives the summary line). */
export function countByRisk(signals: FingerprintSignal[]): Record<RiskLevel, number> {
  const counts: Record<RiskLevel, number> = { low: 0, medium: 0, high: 0 };
  for (const signal of signals) counts[signal.risk] += 1;
  return counts;
}

/** The exact byte string the fingerprint hash is computed over. */
export function concatSignalValues(signals: FingerprintSignal[]): string {
  return signals.map((s) => s.value).join('|');
}

/** SHA-256 hex digest via the Web Crypto API. */
export async function sha256Hex(input: string): Promise<string> {
  const data = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}

/**
 * Stable fingerprint ID: SHA-256 over every signal value, joined in signal
 * order. Same browser state → same hash; any single changed value → new hash.
 */
export async function hashFingerprint(signals: FingerprintSignal[]): Promise<string> {
  return sha256Hex(concatSignalValues(signals));
}

// ---------------------------------------------------------------------------
// Signal collection (browser APIs — not unit-testable, guarded instead)
// ---------------------------------------------------------------------------

const NOT_AVAILABLE = 'not available';

/** Navigator surface this tool reads (some fields are non-standard/optional). */
interface FingerprintNavigator extends Navigator {
  deviceMemory?: number;
  userAgentData?: { platform?: string };
  connection?: { effectiveType?: string };
}

/** Run one probe; any throw / null / undefined degrades to "not available". */
async function probe(
  id: string,
  getValue: () => string | null | undefined | Promise<string | null | undefined>
): Promise<FingerprintSignal> {
  let value: string;
  try {
    const raw = await getValue();
    value = raw === undefined || raw === null || raw === '' ? NOT_AVAILABLE : String(raw);
  } catch {
    value = NOT_AVAILABLE;
  }
  return buildSignal(id, value);
}

/** Hash of an offscreen canvas render: text + shapes + gradient + emoji.
 *  GPU/font/antialiasing differences make this near-unique per machine. */
async function canvasFingerprint(): Promise<string> {
  const canvas = document.createElement('canvas');
  canvas.width = 240;
  canvas.height = 60;
  const ctx = canvas.getContext('2d');
  if (!ctx) return NOT_AVAILABLE;
  ctx.textBaseline = 'top';
  ctx.font = '16px Arial';
  const gradient = ctx.createLinearGradient(0, 0, 240, 60);
  gradient.addColorStop(0, '#3b82f6');
  gradient.addColorStop(1, '#34d399');
  ctx.fillStyle = '#f87171';
  ctx.fillRect(0, 0, 110, 30);
  ctx.fillStyle = gradient;
  ctx.fillText('CosmoDev 🌌 fingerprint 0123 🚀', 4, 4);
  ctx.strokeStyle = '#34d399';
  ctx.beginPath();
  ctx.arc(190, 30, 22, 0, Math.PI * 2);
  ctx.stroke();
  return (await sha256Hex(canvas.toDataURL())).slice(0, 32);
}

/** Unmasked GPU strings via WEBGL_debug_renderer_info (masked fallback). */
function webglInfo(key: 'renderer' | 'vendor'): string {
  const canvas = document.createElement('canvas');
  const gl = (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) as
    | WebGLRenderingContext
    | null;
  if (!gl) return NOT_AVAILABLE;
  const ext = gl.getExtension('WEBGL_debug_renderer_info');
  if (ext) {
    return key === 'renderer'
      ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL))
      : String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL));
  }
  // Privacy-conscious browsers block the extension; the masked value is honest.
  return key === 'renderer' ? String(gl.getParameter(gl.RENDERER)) : String(gl.getParameter(gl.VENDOR));
}

/** 50 common fonts probed by measuring a test string against a fallback. */
const PROBE_FONTS = [
  'Arial', 'Helvetica', 'Helvetica Neue', 'Geneva', 'SF Pro Text', 'Segoe UI',
  'Roboto', 'Ubuntu', 'Cantarell', 'DejaVu Sans', 'Liberation Sans', 'Noto Sans',
  'Verdana', 'Tahoma', 'Trebuchet MS', 'Calibri', 'Candara', 'Corbel',
  'Century Gothic', 'Arial Narrow', 'Arial Black', 'Arial Rounded MT Bold',
  'Impact', 'Times New Roman', 'Times', 'Georgia', 'Garamond', 'Palatino',
  'Baskerville', 'Bookman', 'Book Antiqua', 'Cambria', 'Constantia', 'Didot',
  'Perpetua', 'Rockwell', 'Bodoni MT', 'Big Caslon', 'Courier', 'Courier New',
  'Consolas', 'Menlo', 'Monaco', 'Andale Mono', 'Lucida Console',
  'Lucida Grande', 'Lucida Sans Unicode', 'Comic Sans MS', 'Brush Script MT',
  'Optima',
];

const PROBE_TEXT = 'mmmmmmmmmmlli WwMm 0123';

/** Detect installed fonts: a font renders the probe text at a different
 *  width than the monospace fallback only if it is actually available. */
function detectFonts(): string {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  if (!ctx) return NOT_AVAILABLE;
  ctx.font = '72px monospace';
  const baseline = ctx.measureText(PROBE_TEXT).width;
  const found = PROBE_FONTS.filter((font) => {
    ctx.font = `72px "${font}", monospace`;
    return ctx.measureText(PROBE_TEXT).width !== baseline;
  });
  if (found.length === 0) return `none of ${PROBE_FONTS.length} probed`;
  const shown = found.slice(0, 8).join(', ');
  return `${found.length}/${PROBE_FONTS.length}: ${shown}${found.length > 8 ? '…' : ''}`;
}

/**
 * Collect every signal, in SIGNAL_DEFINITIONS order (the hash depends on this
 * order staying stable). Each probe is independent; failures degrade to
 * "not available" rather than aborting the collection.
 */
export async function collectFingerprint(): Promise<FingerprintSignal[]> {
  const nav = navigator as FingerprintNavigator;

  const doNotTrack = (): string => {
    const dnt = nav.doNotTrack;
    if (dnt === '1') return 'enabled (1)';
    if (dnt === '0') return 'disabled (0)';
    return 'not set';
  };

  const pdfViewer = (): string => {
    if (nav.pdfViewerEnabled === undefined) return 'unknown';
    return nav.pdfViewerEnabled ? 'built-in PDF viewer' : 'no PDF viewer';
  };

  const probes: [string, () => string | null | undefined | Promise<string | null | undefined>][] = [
    // High risk
    ['canvas', canvasFingerprint],
    ['webgl-renderer', () => webglInfo('renderer')],
    ['webgl-vendor', () => webglInfo('vendor')],
    ['fonts', detectFonts],
    ['timezone', () => Intl.DateTimeFormat().resolvedOptions().timeZone],
    // Medium risk
    ['screen', () => `${screen.width}×${screen.height} @ ${screen.colorDepth}-bit`],
    ['device-pixel-ratio', () => String(window.devicePixelRatio)],
    ['hardware-concurrency', () => (nav.hardwareConcurrency ? `${nav.hardwareConcurrency} threads` : undefined)],
    ['device-memory', () => (nav.deviceMemory ? `~${nav.deviceMemory} GB` : undefined)],
    ['platform', () => nav.userAgentData?.platform ?? nav.platform],
    ['languages', () => nav.languages?.join(', ') ?? nav.language],
    ['touch', () => (nav.maxTouchPoints > 0 ? `${nav.maxTouchPoints} touch points` : 'no touch')],
    ['connection', () => nav.connection?.effectiveType],
    // Low risk
    ['user-agent', () => nav.userAgent],
    ['do-not-track', doNotTrack],
    ['cookies-enabled', () => (nav.cookieEnabled ? 'enabled' : 'disabled')],
    ['online', () => (nav.onLine ? 'online' : 'offline')],
    ['pdf-viewer', pdfViewer],
  ];

  const signals: FingerprintSignal[] = [];
  for (const [id, getValue] of probes) {
    signals.push(await probe(id, getValue));
  }
  return signals;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →