package authcaptcha import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "fmt" "image" "image/color" "image/png" "io" "strings" "sync" "time" ) const ( defaultTTL = 2 * time.Minute defaultCapacity = 256 maxPerSubject = 4 answerLength = 8 ) var digitGlyphs = map[byte][7]string{ '2': {"11111", "00001", "00001", "11111", "10000", "10000", "11111"}, '3': {"11111", "00001", "00001", "01111", "00001", "00001", "11111"}, '4': {"10001", "10001", "10001", "11111", "00001", "00001", "00001"}, '5': {"11111", "10000", "10000", "11111", "00001", "00001", "11111"}, '6': {"11111", "10000", "10000", "11111", "10001", "10001", "11111"}, '7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"}, '8': {"11111", "10001", "10001", "11111", "10001", "10001", "11111"}, '9': {"11111", "10001", "10001", "11111", "00001", "00001", "11111"}, } type Challenge struct { ChallengeID string `json:"challenge_id"` ImageDataURL string `json:"image_data_url"` ExpiresAt time.Time `json:"expires_at"` } type Service interface { Create(subject string) (Challenge, error) Verify(subject, challengeID, answer string) bool } type Options struct { Clock func() time.Time Random io.Reader TTL time.Duration Capacity int } type entry struct { subject string digest [sha256.Size]byte createdAt time.Time expiresAt time.Time } type Manager struct { mu sync.Mutex clock func() time.Time random io.Reader ttl time.Duration capacity int entries map[string]entry } func New(options Options) *Manager { clock := options.Clock if clock == nil { clock = time.Now } randomSource := options.Random if randomSource == nil { randomSource = rand.Reader } ttl := options.TTL if ttl <= 0 { ttl = defaultTTL } capacity := options.Capacity if capacity <= 0 { capacity = defaultCapacity } return &Manager{clock: clock, random: randomSource, ttl: ttl, capacity: capacity, entries: make(map[string]entry)} } func (m *Manager) Create(subject string) (Challenge, error) { idBytes := make([]byte, 18) answerBytes := make([]byte, answerLength) noiseBytes := make([]byte, 256) if _, err := io.ReadFull(m.random, idBytes); err != nil { return Challenge{}, fmt.Errorf("generate captcha id: %w", err) } if _, err := io.ReadFull(m.random, answerBytes); err != nil { return Challenge{}, fmt.Errorf("generate captcha answer: %w", err) } if _, err := io.ReadFull(m.random, noiseBytes); err != nil { return Challenge{}, fmt.Errorf("generate captcha image noise: %w", err) } const alphabet = "23456789" for index := range answerBytes { answerBytes[index] = alphabet[int(answerBytes[index])%len(alphabet)] } id := base64.RawURLEncoding.EncodeToString(idBytes) answer := string(answerBytes) now := m.clock().UTC() expiresAt := now.Add(m.ttl) imageDataURL, err := renderPNG(answer, noiseBytes) if err != nil { return Challenge{}, err } m.mu.Lock() defer m.mu.Unlock() m.pruneLocked(now) m.evictSubjectOverflowLocked(strings.TrimSpace(subject)) for len(m.entries) >= m.capacity { m.evictOldestLocked() } m.entries[id] = entry{ subject: strings.TrimSpace(subject), digest: challengeDigest(subject, id, answer), createdAt: now, expiresAt: expiresAt, } return Challenge{ChallengeID: id, ImageDataURL: imageDataURL, ExpiresAt: expiresAt}, nil } func (m *Manager) evictSubjectOverflowLocked(subject string) { count := 0 oldestID := "" var oldest time.Time for id, item := range m.entries { if item.subject != subject { continue } count++ if oldestID == "" || item.createdAt.Before(oldest) { oldestID, oldest = id, item.createdAt } } if count >= maxPerSubject && oldestID != "" { delete(m.entries, oldestID) } } func (m *Manager) Verify(subject, challengeID, answer string) bool { id := strings.TrimSpace(challengeID) if id == "" || strings.TrimSpace(answer) == "" { return false } now := m.clock().UTC() m.mu.Lock() defer m.mu.Unlock() m.pruneLocked(now) stored, ok := m.entries[id] delete(m.entries, id) if !ok || stored.subject != strings.TrimSpace(subject) || !now.Before(stored.expiresAt) { return false } actual := challengeDigest(subject, id, answer) return subtle.ConstantTimeCompare(actual[:], stored.digest[:]) == 1 } func (m *Manager) pruneLocked(now time.Time) { for id, item := range m.entries { if !now.Before(item.expiresAt) { delete(m.entries, id) } } } func (m *Manager) evictOldestLocked() { oldestID := "" var oldest time.Time for id, item := range m.entries { if oldestID == "" || item.createdAt.Before(oldest) { oldestID, oldest = id, item.createdAt } } if oldestID != "" { delete(m.entries, oldestID) } } func challengeDigest(subject, id, answer string) [sha256.Size]byte { normalized := strings.ToUpper(strings.TrimSpace(answer)) return sha256.Sum256([]byte(strings.TrimSpace(subject) + "\x00" + id + "\x00" + normalized)) } func renderPNG(answer string, noise []byte) (string, error) { const width, height = 184, 52 canvas := image.NewRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ { for x := 0; x < width; x++ { shade := uint8(238 + int(noise[(x+y*width)%len(noise)])%15) canvas.SetRGBA(x, y, color.RGBA{R: shade, G: shade, B: uint8(minInt(255, int(shade)+4)), A: 255}) } } for index := 0; index < 18; index++ { offset := index * 4 x0 := int(noise[offset]) % width y0 := int(noise[offset+1]) % height x1 := int(noise[offset+2]) % width y1 := int(noise[offset+3]) % height drawLine(canvas, x0, y0, x1, y1, color.RGBA{R: 120, G: 145, B: 158, A: 130}) } for index := 0; index < 120; index++ { x := int(noise[(index*2)%len(noise)]) % width y := int(noise[(index*2+1)%len(noise)]) % height canvas.SetRGBA(x, y, color.RGBA{R: 70, G: 104, B: 120, A: 255}) } for index := 0; index < len(answer); index++ { glyph, ok := digitGlyphs[answer[index]] if !ok { return "", fmt.Errorf("captcha renderer received unsupported glyph") } x := 8 + index*22 y := 9 + int(noise[96+index])%5 drawGlyph(canvas, glyph, x, y, 4, color.RGBA{R: uint8(18 + noise[112+index]%45), G: 48, B: 66, A: 255}) } var encoded strings.Builder writer := base64.NewEncoder(base64.StdEncoding, &encoded) if err := png.Encode(writer, canvas); err != nil { _ = writer.Close() return "", fmt.Errorf("encode captcha image: %w", err) } if err := writer.Close(); err != nil { return "", fmt.Errorf("encode captcha image: %w", err) } return "data:image/png;base64," + encoded.String(), nil } func drawGlyph(canvas *image.RGBA, glyph [7]string, originX, originY, scale int, ink color.RGBA) { for row, pattern := range glyph { for column := 0; column < len(pattern); column++ { if pattern[column] != '1' { continue } for dy := 0; dy < scale; dy++ { for dx := 0; dx < scale; dx++ { canvas.SetRGBA(originX+column*scale+dx, originY+row*scale+dy, ink) } } } } } func drawLine(canvas *image.RGBA, x0, y0, x1, y1 int, ink color.RGBA) { dx, sx := absInt(x1-x0), -1 if x0 < x1 { sx = 1 } dy, sy := -absInt(y1-y0), -1 if y0 < y1 { sy = 1 } err := dx + dy for { canvas.SetRGBA(x0, y0, ink) if x0 == x1 && y0 == y1 { return } twice := 2 * err if twice >= dy { err += dy x0 += sx } if twice <= dx { err += dx y0 += sy } } } func absInt(value int) int { if value < 0 { return -value } return value } func minInt(left, right int) int { if left < right { return left } return right }