feat(captcha): FNV-1a + xorshift32 PRNG matching Cap.js widget
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
40ba4ee9de
commit
84723991cd
36
captcha/prng.go
Normal file
36
captcha/prng.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const prngMask = 0xFFFFFFFF
|
||||||
|
|
||||||
|
// prng generates a deterministic lowercase-hex string of length chars from
|
||||||
|
// seed, matching the Cap.js widget PRNG exactly: FNV-1a seeds a 32-bit state,
|
||||||
|
// then xorshift32 produces the hex output. Must stay byte-for-byte compatible
|
||||||
|
// with the widget or solutions will never verify.
|
||||||
|
func prng(seed string, length int) string {
|
||||||
|
state := fnv1a(seed)
|
||||||
|
var b strings.Builder
|
||||||
|
for b.Len() < length {
|
||||||
|
state ^= (state << 13) & prngMask
|
||||||
|
state ^= state >> 17
|
||||||
|
state ^= (state << 5) & prngMask
|
||||||
|
state &= prngMask
|
||||||
|
fmt.Fprintf(&b, "%08x", state)
|
||||||
|
}
|
||||||
|
return b.String()[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// fnv1a returns the 32-bit FNV-1a hash of s. Iterates over runes (code points)
|
||||||
|
// to match the JS/Python reference (which use charCodeAt / ord).
|
||||||
|
func fnv1a(s string) uint32 {
|
||||||
|
var h uint32 = 2166136261
|
||||||
|
for _, ch := range s {
|
||||||
|
h ^= uint32(ch)
|
||||||
|
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & prngMask
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
37
captcha/prng_test.go
Normal file
37
captcha/prng_test.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPrngKnownVectors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
seed string
|
||||||
|
length int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello", 8, "eb492c6e"},
|
||||||
|
{"hello", 16, "eb492c6e1655ea8c"},
|
||||||
|
{"test", 32, "9c7ca3730a4a283aa6e4bc1c1d83b14f"},
|
||||||
|
{"a", 8, "441aaeb8"},
|
||||||
|
{"z", 8, "da40eb31"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := prng(c.seed, c.length); got != c.want {
|
||||||
|
t.Errorf("prng(%q,%d)=%q want %q", c.seed, c.length, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrngLengthAndDeterminism(t *testing.T) {
|
||||||
|
for _, n := range []int{1, 8, 16, 32, 64} {
|
||||||
|
if got := prng("x", n); len(got) != n {
|
||||||
|
t.Errorf("len(prng(\"x\",%d))=%d want %d", n, len(got), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prng("seed1", 16) == prng("seed2", 16) {
|
||||||
|
t.Error("different seeds produced identical output")
|
||||||
|
}
|
||||||
|
a, b := prng("test", 16), prng("test", 16)
|
||||||
|
if a != b {
|
||||||
|
t.Error("prng not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user