diff --git a/captcha/tokens.go b/captcha/tokens.go new file mode 100644 index 0000000..fb6150a --- /dev/null +++ b/captcha/tokens.go @@ -0,0 +1,96 @@ +package captcha + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "time" +) + +// nowMs is a seam so tests can control time. Millisecond epoch to match the +// reference (JS Date.now()). +var nowMs = func() int64 { return time.Now().UnixMilli() } + +type challengeClaims struct { + Nonce string + ExpiresMs int64 + C, S, D int +} + +func sign(secret []byte, payload string) string { + m := hmac.New(sha256.New, secret) + m.Write([]byte(payload)) + return hex.EncodeToString(m.Sum(nil)) +} + +// makeChallengeToken encodes challenge params as "nonce:expires:c:s:d:hmac". +func makeChallengeToken(secret []byte, nonce string, expiresMs int64, c, s, d int) string { + payload := nonce + ":" + strconv.FormatInt(expiresMs, 10) + ":" + + strconv.Itoa(c) + ":" + strconv.Itoa(s) + ":" + strconv.Itoa(d) + return payload + ":" + sign(secret, payload) +} + +func verifyChallengeToken(secret []byte, token string) *challengeClaims { + if token == "" { + return nil + } + i := strings.LastIndex(token, ":") + if i < 0 { + return nil + } + payload, sig := token[:i], token[i+1:] + if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) { + return nil + } + f := strings.Split(payload, ":") + if len(f) != 5 { + return nil + } + expires, err1 := strconv.ParseInt(f[1], 10, 64) + c, err2 := strconv.Atoi(f[2]) + s, err3 := strconv.Atoi(f[3]) + d, err4 := strconv.Atoi(f[4]) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil { + return nil + } + if expires <= nowMs() { + return nil + } + return &challengeClaims{Nonce: f[0], ExpiresMs: expires, C: c, S: s, D: d} +} + +// makeVerificationToken creates "random:expires:hmac". +func makeVerificationToken(secret []byte, expiresMs int64) (string, error) { + buf := make([]byte, 15) + if _, err := rand.Read(buf); err != nil { + return "", err + } + payload := hex.EncodeToString(buf) + ":" + strconv.FormatInt(expiresMs, 10) + return payload + ":" + sign(secret, payload), nil +} + +func verifyVerificationToken(secret []byte, token string) bool { + if token == "" { + return false + } + i := strings.LastIndex(token, ":") + if i < 0 { + return false + } + payload, sig := token[:i], token[i+1:] + if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) { + return false + } + f := strings.Split(payload, ":") + if len(f) != 2 { + return false + } + expires, err := strconv.ParseInt(f[1], 10, 64) + if err != nil { + return false + } + return expires > nowMs() +} diff --git a/captcha/tokens_test.go b/captcha/tokens_test.go new file mode 100644 index 0000000..73ac1ba --- /dev/null +++ b/captcha/tokens_test.go @@ -0,0 +1,48 @@ +package captcha + +import "testing" + +func TestChallengeTokenRoundTrip(t *testing.T) { + secret := []byte("s3cr3t") + tok := makeChallengeToken(secret, "abc123", nowMs()+60_000, 50, 32, 4) + claims := verifyChallengeToken(secret, tok) + if claims == nil { + t.Fatal("valid token rejected") + } + if claims.Nonce != "abc123" || claims.C != 50 || claims.S != 32 || claims.D != 4 { + t.Fatalf("bad claims: %+v", claims) + } +} + +func TestChallengeTokenTamperAndExpiry(t *testing.T) { + secret := []byte("s3cr3t") + tok := makeChallengeToken(secret, "abc123", nowMs()+60_000, 50, 32, 4) + if verifyChallengeToken([]byte("wrong"), tok) != nil { + t.Error("token accepted under wrong secret") + } + if verifyChallengeToken(secret, tok+"x") != nil { + t.Error("tampered token accepted") + } + expired := makeChallengeToken(secret, "abc123", nowMs()-1, 50, 32, 4) + if verifyChallengeToken(secret, expired) != nil { + t.Error("expired token accepted") + } +} + +func TestVerificationToken(t *testing.T) { + secret := []byte("s3cr3t") + vt, err := makeVerificationToken(secret, nowMs()+60_000) + if err != nil { + t.Fatal(err) + } + if !verifyVerificationToken(secret, vt) { + t.Error("valid verification token rejected") + } + if verifyVerificationToken([]byte("wrong"), vt) { + t.Error("verification token accepted under wrong secret") + } + expired, _ := makeVerificationToken(secret, nowMs()-1) + if verifyVerificationToken(secret, expired) { + t.Error("expired verification token accepted") + } +}