proxied.tech
RSS

Building a Proof of Work Captcha with WASM based fingerprinting

2025-09-19 • #go, #wasm, #argon2, #captcha


Building a Proof-of-Work Captcha with WASM and Argon2

A Captcha solution that relies entirely on Proof of Work computation and WASM based fingerprinting.

The Architecture: Computation Meets Fingerprinting

The system operates on two parallel verification tracks:

  1. Argon2 Proof-of-Work: Browser-based computational challenge that takes 3-8 seconds to solve
  2. WASM Fingerprinting: Comprehensive browser fingerprint collection with AES-256 encryption
  3. PostgreSQL Storage: Persistent challenge tracking with automatic cleanup

The power is in the combination. While the browser is grinding through Argon2 hashing, the WASM module is quietly collecting fingerprint data. Both pieces need to validate server-side for the Captcha to pass.

Argon2: The Computational Challenge

The heart of the proof-of-work system uses Argon2id, specifically tuned to be annoying for bots but tolerable for humans:

type Challenge struct {
    ID         string    `db:"id" json:"id"`
    Salt       string    `db:"salt" json:"salt"`
    Difficulty uint32    `db:"difficulty" json:"difficulty"`
    Memory     uint32    `db:"memory" json:"memory"`
    Threads    uint8     `db:"threads" json:"threads"`
    KeyLen     uint32    `db:"key_len" json:"keyLen"`
    Target     string    `db:"target" json:"target"`
    CreatedAt  time.Time `db:"created_at" json:"createdAt"`
    ExpiresAt  time.Time `db:"expires_at" json:"expiresAt"`
}

The challenge generation is carefully calibrated. We're using relatively modest parameters - 16MB memory, 1 iteration, single thread - because this runs in browsers, not on dedicated hardware. The real difficulty comes from the target prefix requirement:

func (s *Service) GenerateChallenge() (*database.Challenge, error) {
    salt := make([]byte, s.cfg.Argon2SaltLength)
    rand.Read(salt)

    challenge := &database.Challenge{
        ID:         hex.EncodeToString(challengeID),
        Salt:       base64.StdEncoding.EncodeToString(salt),
        Difficulty: s.cfg.Argon2Time,        // 1 iteration
        Memory:     s.cfg.Argon2Memory,      // 16384 KB
        Threads:    s.cfg.Argon2Threads,     // 1 thread
        KeyLen:     s.cfg.Argon2KeyLength,   // 32 bytes
        Target:     s.cfg.Argon2TargetPrefix, // "00" = 2 leading zeros
        CreatedAt:  time.Now(),
        ExpiresAt:  time.Now().Add(5 * time.Minute),
    }

    return challenge, s.db.CreateChallenge(challenge)
}

The target prefix is where the real work happens. Requiring two leading zeros means on average you need 256 attempts to find a valid nonce. With Argon2 taking ~20-30ms per hash on typical hardware, this creates the 3-8 second solve window we're targeting.

Browser-Side Argon2: The JavaScript Challenge

The client-side implementation uses the argon2-browser library, which provides WASM-accelerated Argon2 in browsers:

async solveChallenge() {
    this.solving = true;
    this.updateStatus('Solving...', 'working');

    let nonce = 0;
    const startTime = Date.now();

    while (this.solving) {
        const nonceStr = nonce.toString();
        const input = this.challenge.salt + nonceStr;

        const saltBytes = this.base64ToUint8Array(this.challenge.salt);

        const result = await argon2.hash({
            pass: input,
            salt: saltBytes,
            time: this.challenge.difficulty,
            mem: this.challenge.memory,
            parallelism: this.challenge.threads,
            hashLen: this.challenge.keyLen,
            type: argon2.ArgonType.Argon2id
        });

        const hashStr = this.uint8ArrayToHex(result.hash);

        if (this.hasValidPrefix(hashStr, this.challenge.target)) {
            return {
                challenge: this.challenge,
                nonce: nonceStr,
                hash: hashStr
            };
        }

        nonce++;

        // Yield control periodically to prevent UI freezing
        if (nonce % 10 === 0) {
            await this.sleep(1);
        }
    }
}

WASM Fingerprinting: The Silent Observer

While Argon2 is grinding away, our WASM module is collecting comprehensive browser fingerprints. The WASM approach is key here because it's much harder to inspect and modify than regular JavaScript:

// collectFingerprint gathers browser fingerprinting data
func collectFingerprint(this js.Value, args []js.Value) interface{} {
    window := js.Global().Get("window")
    navigator := window.Get("navigator")
    screen := window.Get("screen")

    fingerprint := FingerprintData{
        UserAgent:           navigator.Get("userAgent").String(),
        Language:            navigator.Get("language").String(),
        Platform:            navigator.Get("platform").String(),
        HardwareConcurrency: navigator.Get("hardwareConcurrency").Int(),
        MaxTouchPoints:      navigator.Get("maxTouchPoints").Int(),
        ColorDepth:          screen.Get("colorDepth").Int(),
        PixelRatio:          window.Get("devicePixelRatio").Float(),
        CookieEnabled:       navigator.Get("cookieEnabled").Bool(),
    }

    // Get timezone offset
    date := js.Global().Get("Date").New()
    timezoneOffset := date.Call("getTimezoneOffset").Int()
    fingerprint.Timezone = fmt.Sprintf("%d", timezoneOffset)

    // Get screen resolutions
    fingerprint.ScreenResolution = fmt.Sprintf("%dx%d", 
        screen.Get("width").Int(), 
        screen.Get("height").Int())

    fingerprint.AvailableScreenResolution = fmt.Sprintf("%dx%d", 
        screen.Get("availWidth").Int(), 
        screen.Get("availHeight").Int())

The fingerprint data collection is comprehensive but not excessive. We're grabbing the attributes that are most stable: hardware concurrency, screen properties, platform details, and timezone information.

The Encryption Pipeline: Hiding in Plain Sight

The fingerprint data goes through a multi-stage obfuscation process before transmission:

// Convert to JSON
jsonData, err := json.Marshal(fingerprint)
if err != nil {
    return map[string]interface{}{
        "success": false,
        "error":   "Failed to serialize fingerprint",
    }
}

// Encode to base64
b64Data := base64.StdEncoding.EncodeToString(jsonData)

// Reverse the bytes (simple obfuscation)
reversedData := reverseString(b64Data)

// Encrypt with AES-256-GCM
encryptedData, err := encrypt([]byte(reversedData), aesKey)
if err != nil {
    return map[string]interface{}{
        "success": false,
        "error":   "Failed to encrypt fingerprint",
    }
}

The AES key is hardcoded directly into the WASM binary, making it much harder to extract than if it were in JavaScript. The encryption uses GCM mode for authenticated encryption:

func encrypt(plaintext []byte, key []byte) (string, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return "", fmt.Errorf("failed to create cipher: %w", err)
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return "", fmt.Errorf("failed to create GCM: %w", err)
    }

    nonce := make([]byte, gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return "", fmt.Errorf("failed to generate nonce: %w", err)
    }

    ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
    return base64.StdEncoding.EncodeToString(ciphertext), nil
}

The server-side decryption reverses this process, validating both the encryption and the fingerprint data formats.

Server-Side Validation: Double Verification

The server validates both the Argon2 solution and the fingerprint data independently:

func (s *Service) VerifySolution(challengeID, nonce, hash string, fingerprint string, clientIP, userAgent string) (*database.Solution, error) {
    // Get challenge from database
    challenge, err := s.db.GetChallenge(challengeID)
    if err != nil {
        return nil, fmt.Errorf("failed to get challenge: %w", err)
    }

    // Verify the Argon2 solution
    valid, err := s.verifySolution(challenge, nonce, hash)
    if err != nil {
        return nil, fmt.Errorf("failed to verify solution: %w", err)
    }

    // Store everything in the database
    solution := &database.Solution{
        ID:          hex.EncodeToString(solutionID),
        ChallengeID: challengeID,
        Nonce:       nonce,
        Hash:        hash,
        Fingerprint: fingerprint,
        ClientIP:    clientIP,
        UserAgent:   userAgent,
        CreatedAt:   time.Now(),
        Valid:       valid,
    }

    return solution, s.db.CreateSolution(solution)
}

The Argon2 verification recomputes the hash server-side to ensure it matches:

func (s *Service) verifySolution(challenge *database.Challenge, nonce, providedHash string) (bool, error) {
    salt, err := base64.StdEncoding.DecodeString(challenge.Salt)
    if err != nil {
        return false, fmt.Errorf("failed to decode salt: %w", err)
    }

    inputData := challenge.Salt + nonce

    hash := argon2.IDKey(
        []byte(inputData),
        salt,
        challenge.Difficulty,
        challenge.Memory,
        challenge.Threads,
        challenge.KeyLen,
    )

    computedHash := hex.EncodeToString(hash)

    return computedHash == providedHash && s.hasValidPrefix(computedHash, challenge.Target), nil
}

Fingerprint Validation: Ensuring Authenticity

The fingerprint validation is equally thorough, checking not just that the data decrypts properly, but that all values are within expected ranges:

func (v *Validator) validateFingerprintFields(fp *database.FingerprintData) error {
    // Validate hardware concurrency (1-128 cores)
    if fp.HardwareConcurrency < 1 || fp.HardwareConcurrency > 128 {
        return fmt.Errorf("hardware concurrency out of range")
    }

    // Validate color depth
    validDepths := []int{8, 16, 24, 30, 32, 48}
    found := false
    for _, valid := range validDepths {
        if fp.ColorDepth == valid {
            found = true
            break
        }
    }
    if !found {
        return fmt.Errorf("color depth not valid")
    }

    // Validate screen resolution format
    parts := strings.Split(fp.ScreenResolution, "x")
    if len(parts) != 2 {
        return fmt.Errorf("screen resolution format invalid")
    }

    width, err := strconv.Atoi(parts[0])
    if err != nil || width < 100 || width > 10000 {
        return fmt.Errorf("screen width out of range")
    }

    return nil
}

This validation catches automated tools that might try to submit fake fingerprint data with impossible or suspicious values.

Performance Tuning: Finding the Sweet Spot

The Argon2 parameters are carefully tuned for browser performance. Too aggressive and legitimate users get frustrated:

# Current settings target 3-8 second solve time
ARGON2_TIME=1          # Single iteration
ARGON2_MEMORY=16384    # 16MB memory usage
ARGON2_THREADS=1       # Single-threaded
ARGON2_TARGET_PREFIX=00 # 2 leading zeros (1/256 probability)

The memory parameter is particularly important. Argon2's memory-hardness means that even with specialized hardware, you can't easily speed up the computation by throwing more processing power at it - you need the memory bandwidth too.

WASM Build Process: Go to WASM

The WASM module is built directly from Go, which gives us type safety and familiar tooling:

# Build the WASM module
export GOOS=js
export GOARCH=wasm
go build -o web/fingerprint.wasm wasm/main.go

The Go-to-WASM compilation creates a surprisingly compact binary (around 3MB) that includes the entire Go runtime. The startup cost is minimal because the WASM module stays loaded throughout the user session.

Database Design: Tracking Everything

The PostgreSQL schema tracks both challenges and solutions with proper relationships:

CREATE TABLE challenges (
    id VARCHAR(255) PRIMARY KEY,
    salt VARCHAR(255) NOT NULL,
    difficulty INTEGER NOT NULL,
    memory INTEGER NOT NULL,
    threads INTEGER NOT NULL,
    key_len INTEGER NOT NULL,
    target VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    solved BOOLEAN NOT NULL DEFAULT FALSE,
    solved_at TIMESTAMP WITH TIME ZONE
);

CREATE TABLE solutions (
    id VARCHAR(255) PRIMARY KEY,
    challenge_id VARCHAR(255) NOT NULL REFERENCES challenges(id),
    nonce VARCHAR(255) NOT NULL,
    hash VARCHAR(255) NOT NULL,
    fingerprint TEXT NOT NULL,
    client_ip VARCHAR(45) NOT NULL,
    user_agent TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    valid BOOLEAN NOT NULL DEFAULT FALSE
);

The automatic cleanup process prevents database bloat:

func startCleanupRoutine(db *database.DB, cfg *config.Config) {
    ticker := time.NewTicker(time.Duration(cfg.ChallengeCleanupIntervalMins) * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        // Clean up expired challenges
        if err := db.CleanupExpiredChallenges(); err != nil {
            log.Printf("Failed to cleanup expired challenges: %v", err)
        }

        // Clean up old solutions (keep for 24 hours)
        if err := db.CleanupOldSolutions(24 * time.Hour); err != nil {
            log.Printf("Failed to cleanup old solutions: %v", err)
        }
    }
}

Real-World Performance and Limitations

In practice, this system creates a significant barrier for automated tools while remaining reasonably user-friendly. The 3-8 second solve time is long enough to be annoying for bots running at scale, but short enough that humans don't abandon the process.

However, it's important to be realistic about Argon2's limitations. While memory-hard, it's not magic. Dedicated hardware with sufficient memory bandwidth can still solve these challenges faster than intended. The real strength is in the combination of computational cost and fingerprint validation.

The fingerprint validation catches many automated tools that might solve the Argon2 challenge but fail to provide realistic browser fingerprints. Tools like Selenium or Puppeteer often have detectable fingerprint characteristics.

Configuration and Deployment

The system is highly configurable through environment variables:

type Config struct {
    // Argon2 settings
    Argon2Time         uint32
    Argon2Memory       uint32
    Argon2Threads      uint8
    Argon2TargetPrefix string

    // Encryption settings
    AESKey string

    // Database settings
    DBHost     string
    DBPort     int
    DBName     string
    DBUser     string
    DBPassword string
}

This makes it easy to tune the difficulty based on your specific use case and user tolerance.

Future Improvements and Considerations

This architecture is extensible in several interesting directions:

  1. Adaptive difficulty: Adjust Argon2 parameters based on client hardware capabilities
  2. Enhanced fingerprinting: Add canvas fingerprinting, WebGL detection, or audio fingerprinting
  3. Machine learning validation: Use the collected fingerprint data to train models for bot detection

Conclusion: Computational Challenges in the Browser

This proof-of-work Captcha represents a different approach to bot detection. Instead of asking users to identify objects in images, we're asking their browsers to do computational work while we observe their behavior.

The combination of Argon2 proof-of-work and WASM fingerprinting creates a multi-layered challenge that's significantly harder for automated tools to bypass than traditional Captchas. While not unbreakable, it raises the bar considerably for bots.

The real beauty is in how the system leverages WASM for performance and obfuscation, Argon2 for memory-hard computation, and encrypted communication for data protection. It's a glimpse into how web security might evolve as traditional Captcha approaches become less effective.

Code is opensourced and available for anyone - Github