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:
- Argon2 Proof-of-Work: Browser-based computational challenge that takes 3-8 seconds to solve
- WASM Fingerprinting: Comprehensive browser fingerprint collection with AES-256 encryption
- 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:
- Adaptive difficulty: Adjust Argon2 parameters based on client hardware capabilities
- Enhanced fingerprinting: Add canvas fingerprinting, WebGL detection, or audio fingerprinting
- 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