Skip to content

Browser Fingerprint Viewer — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Browser Fingerprint — pure logic (Swift port): the 18-signal privacy-risk
// registry with canonical grouping, risk counts, and a stable SHA-256
// fingerprint hash.
//
// Language: Swift 5.9 — standard library, plus Foundation only for
//           String(format:) in the hex encoder.
// Source: CosmoDev polyglot showcase port; canonical = src/lib/browser-fingerprint.ts
//         + this tool's python.py / rust.rs.
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Ported from the TypeScript reference. Covers the portable core: the
// 18-signal registry with privacy-risk classifications, canonical category
// grouping, risk counts, and the stable SHA-256 fingerprint hash. The
// browser-API collection layer (canvas / WebGL / font probes) is DOM-bound
// by design and does not port.
//
// SHA-256 note: Apple platforms ship CryptoKit (import Crypto;
// SHA256.hash(data:)) and Linux has the swift-crypto package, but neither
// belongs to the standard library. This snippet hand-rolls FIPS 180-4
// SHA-256 only to stay dependency-free; the "abc" check in the demo below
// pins the implementation to the specification.

import Foundation

/// Privacy-risk classification.
enum RiskLevel: String {
    /// Near-unique on its own or part of a rare combination.
    case high = "high"
    /// Narrows you to a sizable-but-specific population.
    case medium = "medium"
    /// Coarse; shared by millions of browsers.
    case low = "low"
}

/// Groups related signals.
enum FingerprintCategory: String {
    case hardware = "Hardware"
    case graphics = "Graphics"
    case network = "Network"
    case browser = "Browser"
    case input = "Input"
}

/// Canonical category order (card grid, top to bottom).
let fingerprintCategories: [FingerprintCategory] = [
    .hardware, .graphics, .network, .browser, .input,
]

/// One collected, classified signal.
struct FingerprintSignal {
    let id: String
    let label: String
    var value: String
    let risk: RiskLevel
    let category: FingerprintCategory
}

/// Classifies one signal id.
struct SignalDef {
    let label: String
    let risk: RiskLevel
    let category: FingerprintCategory
}

/// Static registry of every signal the tool collects, with its privacy-risk
/// classification. Single source of truth: buildSignal derives from it, so a
/// signal can never be collected without being classified.
let signalDefinitions: [String: SignalDef] = [
    // High risk
    "canvas": SignalDef(label: "Canvas fingerprint", risk: .high, category: .graphics),
    "webgl-renderer": SignalDef(label: "WebGL renderer", risk: .high, category: .graphics),
    "webgl-vendor": SignalDef(label: "WebGL vendor", risk: .high, category: .graphics),
    "fonts": SignalDef(label: "Installed fonts", risk: .high, category: .browser),
    "timezone": SignalDef(label: "Timezone", risk: .high, category: .browser),
    // Medium risk
    "screen": SignalDef(label: "Screen & color depth", risk: .medium, category: .hardware),
    "device-pixel-ratio": SignalDef(label: "Device pixel ratio", risk: .medium, category: .hardware),
    "hardware-concurrency": SignalDef(label: "CPU cores", risk: .medium, category: .hardware),
    "device-memory": SignalDef(label: "Device Memory", risk: .medium, category: .hardware),
    "platform": SignalDef(label: "Platform", risk: .medium, category: .browser),
    "languages": SignalDef(label: "Languages", risk: .medium, category: .browser),
    "touch": SignalDef(label: "Touch support", risk: .medium, category: .input),
    "connection": SignalDef(label: "Connection type", risk: .medium, category: .network),
    // Low risk
    "user-agent": SignalDef(label: "User agent", risk: .low, category: .browser),
    "do-not-track": SignalDef(label: "Do Not Track", risk: .low, category: .browser),
    "cookies-enabled": SignalDef(label: "Cookies enabled", risk: .low, category: .browser),
    "online": SignalDef(label: "Online status", risk: .low, category: .network),
    "pdf-viewer": SignalDef(label: "PDF viewer", risk: .low, category: .browser),
]

/// Unknown signal id — a typo'd id fails loudly instead of silently
/// rendering an unclassified row.
enum FingerprintError: Error {
    case unknownSignal(String)
}

/// Risk level for a known signal id; nil for unknown ids.
func classifySignalRisk(_ id: String) -> RiskLevel? {
    signalDefinitions[id]?.risk
}

/// Build a signal from its id and collected value.
func buildSignal(_ id: String, _ value: String) throws -> FingerprintSignal {
    guard let def = signalDefinitions[id] else {
        throw FingerprintError.unknownSignal(id)
    }
    return FingerprintSignal(id: id, label: def.label, value: value, risk: def.risk, category: def.category)
}

/// One category and its signals.
struct CategoryGroup {
    let category: FingerprintCategory
    let signals: [FingerprintSignal]
}

/// Group signals by category in canonical order, omitting empty categories.
func groupByCategory(_ signals: [FingerprintSignal]) -> [CategoryGroup] {
    fingerprintCategories.compactMap { category in
        let members = signals.filter { $0.category == category }
        return members.isEmpty ? nil : CategoryGroup(category: category, signals: members)
    }
}

/// Signals per risk level (drives the summary line).
struct RiskCounts {
    var low = 0
    var medium = 0
    var high = 0
}

/// Count signals per risk level.
func countByRisk(_ signals: [FingerprintSignal]) -> RiskCounts {
    var counts = RiskCounts()
    for signal in signals {
        switch signal.risk {
        case .low: counts.low += 1
        case .medium: counts.medium += 1
        case .high: counts.high += 1
        }
    }
    return counts
}

/// The exact byte string the fingerprint hash is computed over: every value
/// joined with "|" in signal order.
func concatSignalValues(_ signals: [FingerprintSignal]) -> String {
    signals.map(\.value).joined(separator: "|")
}

// MARK: SHA-256 (FIPS 180-4) — dependency-free, pinned by the "abc" vector

/// Round constants: first 32 bits of the fractional parts of the cube roots
/// of the first 64 primes.
private let sha256K: [UInt32] = [
    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]

/// Initial hash words: first 32 bits of the fractional parts of the square
/// roots of the first 8 primes.
private let sha256H0: [UInt32] = [
    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]

private func rotr(_ x: UInt32, _ n: UInt32) -> UInt32 {
    (x >> n) | (x << (32 - n))
}

/// Minimal SHA-256 state machine (single-shot digest over a full message).
private struct Sha256 {
    var state: [UInt32] = sha256H0

    /// Process one 64-byte block through the 64-round compression function.
    private mutating func compress(_ block: ArraySlice<UInt8>) {
        var w = [UInt32](repeating: 0, count: 64)
        for t in 0..<16 {
            let base = block.startIndex + 4 * t
            w[t] = (UInt32(block[base]) << 24) | (UInt32(block[base + 1]) << 16)
                | (UInt32(block[base + 2]) << 8) | UInt32(block[base + 3])
        }
        for t in 16..<64 {
            let s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >> 3)
            let s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >> 10)
            w[t] = w[t - 16] &+ s0 &+ w[t - 7] &+ s1
        }

        var a = state[0], b = state[1], c = state[2], d = state[3]
        var e = state[4], f = state[5], g = state[6], h = state[7]
        for t in 0..<64 {
            let bigS1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
            let ch = (e & f) ^ (~e & g)
            let temp1 = h &+ bigS1 &+ ch &+ sha256K[t] &+ w[t]
            let bigS0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
            let maj = (a & b) ^ (a & c) ^ (b & c)
            let temp2 = bigS0 &+ maj

            h = g; g = f; f = e; e = d &+ temp1
            d = c; c = b; b = a; a = temp1 &+ temp2
        }

        state[0] = state[0] &+ a
        state[1] = state[1] &+ b
        state[2] = state[2] &+ c
        state[3] = state[3] &+ d
        state[4] = state[4] &+ e
        state[5] = state[5] &+ f
        state[6] = state[6] &+ g
        state[7] = state[7] &+ h
    }

    /// Pad (0x80, zeros, 64-bit big-endian bit length) and digest the whole
    /// message, returning the 32-byte hash.
    mutating func digest(_ message: [UInt8]) -> [UInt8] {
        let bitLength = UInt64(message.count) &* 8
        var padded = message
        padded.append(0x80)
        while padded.count % 64 != 56 {
            padded.append(0)
        }
        for shift in stride(from: 56, through: 0, by: -8) {
            padded.append(UInt8(truncatingIfNeeded: bitLength >> UInt64(shift)))
        }

        for start in stride(from: 0, to: padded.count, by: 64) {
            compress(padded[start..<(start + 64)])
        }

        var out = [UInt8]()
        out.reserveCapacity(32)
        for word in state {
            out.append(UInt8(truncatingIfNeeded: word >> 24))
            out.append(UInt8(truncatingIfNeeded: word >> 16))
            out.append(UInt8(truncatingIfNeeded: word >> 8))
            out.append(UInt8(truncatingIfNeeded: word))
        }
        return out
    }
}

/// SHA-256 hex digest of the UTF-8 bytes of input.
func sha256Hex(_ input: String) -> String {
    var sha = Sha256()
    return sha.digest(Array(input.utf8))
        .map { String(format: "%02x", $0) }
        .joined()
}

/// Stable fingerprint ID: SHA-256 over every signal value, joined in signal
/// order. Same browser state -> same hash; any single changed value -> new
/// hash.
func hashFingerprint(_ signals: [FingerprintSignal]) -> String {
    sha256Hex(concatSignalValues(signals))
}

// Demo (the file is the main file when compiled standalone with
// `swiftc swift.swift`).
do {
    // FIPS 180-4 vector pins the hand-rolled implementation to the spec.
    if sha256Hex("abc") != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
        FileHandle.standardError.write("SHA-256 self-check failed\n".data(using: .utf8)!)
        exit(1)
    }

    let signals = [
        try buildSignal("timezone", "Europe/Paris"),
        try buildSignal("screen", "2560×1440 @ 24-bit"),
        try buildSignal("user-agent", "Mozilla/5.0 (Macintosh)"),
    ]

    for group in groupByCategory(signals) {
        print("[\(group.category.rawValue)]")
        for signal in group.signals {
            print("  \(signal.label): \(signal.value) (\(signal.risk.rawValue) risk)")
        }
    }

    let counts = countByRisk(signals)
    print("risk mix: \(counts.high) high / \(counts.medium) medium / \(counts.low) low")
    print("fingerprint: \(hashFingerprint(signals))")
} catch FingerprintError.unknownSignal(let id) {
    print("Unknown fingerprint signal id: \(id)")
    exit(1)
}

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 →