feat(captcha): HMAC-signed stateless challenge + verification tokens
This commit is contained in:
parent
84723991cd
commit
f5cbb56df7
96
captcha/tokens.go
Normal file
96
captcha/tokens.go
Normal file
@ -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()
|
||||
}
|
||||
48
captcha/tokens_test.go
Normal file
48
captcha/tokens_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user