all repos — captcha @ 90158fbef436f84faed8891b2e525241142bd6a9

Go package captcha implements generation and verification of image and audio CAPTCHAs.

Generate captcha representations deterministically.

WARNING: introduces API incompatibility!

This package generates captcha representations on-the-fly; for instance,
if captcha solution was "123456", every call to NewImage() using this
sequence of digits would generate a different random image containing
"123456"; similarly, NewAudio() would generate a different audio
pronouncing the same sequence: 1, 2, 3, 4, 5, 6.

If a user, instead of storing generated outputs, exposes this
functionality from their server, which is the default and recommended
behaviour, an attacker could try loading the same image or audio over
and over again in attempt to arrive at the most correct optical/voice
recognition result.

Instead of using a global non-deterministic pseudorandom number
generator to distort images and audio, this commit introduces a
deterministic PRNG for each image/audio. This PRNG uses a combination of
a global secret key (generated once during initialization from a system
CSPRNG) and captcha id and solution to produce pseudorandom numbers for
each representation deterministically. Thus, calling NewImage() with the
same captcha id and solution at different times will result in the same
image (ditto for NewAudio).

To make results unique not only for different solutions, but also for
ids, these incompatible changes to public API have been introduced:

NewImage and NewAudio changed from:

  func NewImage(digits []byte, width, height int) *Image
  func NewAudio(digits []byte, lang string) *Audio

to:

  func NewImage(id string, digits []byte, width, height int) *Image
  func NewAudio(id string, digits []byte, lang string) *Audio

That is, they now accept an additional captcha `id` argument.
No other interfaces changed.

Described changes also improved performance of generating captchas.
Dmitry Chestnykh dmitry@codingrobots.com
Sun, 11 May 2014 13:31:22 +0200
commit

90158fbef436f84faed8891b2e525241142bd6a9

parent

26f056818c71476bfce1510edec3f1508d4c624e

M .gitignore.gitignore

@@ -1,26 +1,3 @@

-# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.exe - # Generated test captchas capgen/*.png capgen/*.wav
M LICENSELICENSE

@@ -1,4 +1,4 @@

-Copyright (c) 2011 Dmitry Chestnykh +Copyright (c) 2011-2014 Dmitry Chestnykh <dmitry@codingrobots.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
M README.mdREADME.md

@@ -32,13 +32,13 @@ registering the object with SetCustomStore.

Captchas are created by calling New, which returns the captcha id. Their representations, though, are created on-the-fly by calling WriteImage or -WriteAudio functions. Created representations are not stored anywhere, so +WriteAudio functions. Created representations are not stored anywhere, but subsequent calls to these functions with the same id will write the same -captcha solution, but with a different random representation. Reload -function will create a new different solution for the provided captcha, -allowing users to "reload" captcha if they can't solve the displayed one -without reloading the whole page. Verify and VerifyString are used to -verify that the given solution is the right one for the given captcha id. +captcha solution. Reload function will create a new different solution for the +provided captcha, allowing users to "reload" captcha if they can't solve the +displayed one without reloading the whole page. Verify and VerifyString are +used to verify that the given solution is the right one for the given captcha +id. Server provides an http.Handler which can serve image and audio representations of captchas automatically from the URL. It can also be used

@@ -205,7 +205,7 @@

### func NewAudio - func NewAudio(digits []byte, lang string) *Audio + func NewAudio(id string, digits []byte, lang string) *Audio NewAudio returns a new audio captcha with the given digits, where each digit must be in range 0-9. Digits are pronounced in the given language. If there

@@ -236,7 +236,7 @@

### func NewImage - func NewImage(digits []byte, width, height int) *Image + func NewImage(id string, digits []byte, width, height int) *Image NewImage returns a new captcha image of the given width and height with the given digits, where each digit must be in range 0-9.
M audio.goaudio.go

@@ -1,4 +1,4 @@

-// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file.

@@ -13,9 +13,7 @@ )

const sampleRate = 8000 // Hz -var ( - endingBeepSound []byte -) +var endingBeepSound []byte func init() { endingBeepSound = changeSpeed(beepSound, 1.4)

@@ -24,6 +22,7 @@

type Audio struct { body *bytes.Buffer digitSounds [][]byte + rng siprng } // NewAudio returns a new audio captcha with the given digits, where each digit

@@ -31,8 +30,12 @@ // must be in range 0-9. Digits are pronounced in the given language. If there

// are no sounds for the given language, English is used. // // Possible values for lang are "en", "ru", "zh". -func NewAudio(digits []byte, lang string) *Audio { +func NewAudio(id string, digits []byte, lang string) *Audio { a := new(Audio) + + // Initialize PRNG. + a.rng.Seed(deriveSeed(audioSeedPurpose, id, digits)) + if sounds, ok := digitSounds[lang]; ok { a.digitSounds = sounds } else {

@@ -49,7 +52,7 @@ // Random intervals between digits (including beginning).

intervals := make([]int, len(digits)+1) intdur := 0 for i := range intervals { - dur := randInt(sampleRate, sampleRate*3) // 1 to 3 seconds + dur := a.rng.Int(sampleRate, sampleRate*3) // 1 to 3 seconds intdur += dur intervals[i] = dur }

@@ -121,20 +124,20 @@ return len(waveHeader) + 4 + a.body.Len()

} func (a *Audio) makeBackgroundSound(length int) []byte { - b := makeWhiteNoise(length, 4) + b := a.makeWhiteNoise(length, 4) for i := 0; i < length/(sampleRate/10); i++ { - snd := reversedSound(a.digitSounds[randIntn(10)]) - snd = changeSpeed(snd, randFloat(0.8, 1.4)) - place := randIntn(len(b) - len(snd)) - setSoundLevel(snd, randFloat(0.2, 0.5)) + snd := reversedSound(a.digitSounds[a.rng.Intn(10)]) + snd = changeSpeed(snd, a.rng.Float(0.8, 1.4)) + place := a.rng.Intn(len(b) - len(snd)) + setSoundLevel(snd, a.rng.Float(0.2, 0.5)) mixSound(b[place:], snd) } return b } func (a *Audio) randomizedDigitSound(n byte) []byte { - s := randomSpeed(a.digitSounds[n]) - setSoundLevel(s, randFloat(0.75, 1.2)) + s := a.randomSpeed(a.digitSounds[n]) + setSoundLevel(s, a.rng.Float(0.75, 1.2)) return s }

@@ -148,6 +151,22 @@ }

return n } +func (a *Audio) randomSpeed(b []byte) []byte { + pitch := a.rng.Float(0.9, 1.2) + return changeSpeed(b, pitch) +} + +func (a *Audio) makeWhiteNoise(length int, level uint8) []byte { + noise := a.rng.Bytes(length) + adj := 128 - level/2 + for i, v := range noise { + v %= level + v += adj + noise[i] = v + } + return noise +} + // mixSound mixes src into dst. Dst must have length equal to or greater than // src length. func mixSound(dst, src []byte) {

@@ -195,28 +214,12 @@ }

return b } -func randomSpeed(a []byte) []byte { - pitch := randFloat(0.9, 1.2) - return changeSpeed(a, pitch) -} - func makeSilence(length int) []byte { b := make([]byte, length) for i := range b { b[i] = 128 } return b -} - -func makeWhiteNoise(length int, level uint8) []byte { - noise := randomBytes(length) - adj := 128 - level/2 - for i, v := range noise { - v %= level - v += adj - noise[i] = v - } - return noise } func reversedSound(a []byte) []byte {
M audio_test.goaudio_test.go

@@ -12,18 +12,20 @@

func BenchmarkNewAudio(b *testing.B) { b.StopTimer() d := RandomDigits(DefaultLen) + id := randomId() b.StartTimer() for i := 0; i < b.N; i++ { - NewAudio(d, "") + NewAudio(id, d, "") } } func BenchmarkAudioWriteTo(b *testing.B) { b.StopTimer() d := RandomDigits(DefaultLen) + id := randomId() b.StartTimer() for i := 0; i < b.N; i++ { - a := NewAudio(d, "") + a := NewAudio(id, d, "") n, _ := a.WriteTo(ioutil.Discard) b.SetBytes(n) }
M capgen/main.gocapgen/main.go

@@ -44,9 +44,9 @@ var w io.WriterTo

d := captcha.RandomDigits(*flagLen) switch { case *flagAudio: - w = captcha.NewAudio(d, *flagLang) + w = captcha.NewAudio("", d, *flagLang) case *flagImage: - w = captcha.NewImage(d, *flagImgW, *flagImgH) + w = captcha.NewImage("", d, *flagImgW, *flagImgH) } _, err = w.WriteTo(f) if err != nil {
M captcha.gocaptcha.go

@@ -31,13 +31,13 @@ // registering the object with SetCustomStore.

// // Captchas are created by calling New, which returns the captcha id. Their // representations, though, are created on-the-fly by calling WriteImage or -// WriteAudio functions. Created representations are not stored anywhere, so +// WriteAudio functions. Created representations are not stored anywhere, but // subsequent calls to these functions with the same id will write the same -// captcha solution, but with a different random representation. Reload -// function will create a new different solution for the provided captcha, -// allowing users to "reload" captcha if they can't solve the displayed one -// without reloading the whole page. Verify and VerifyString are used to -// verify that the given solution is the right one for the given captcha id. +// captcha solution. Reload function will create a new different solution for +// the provided captcha, allowing users to "reload" captcha if they can't solve +// the displayed one without reloading the whole page. Verify and VerifyString +// are used to verify that the given solution is the right one for the given +// captcha id. // // Server provides an http.Handler which can serve image and audio // representations of captchas automatically from the URL. It can also be used

@@ -110,7 +110,7 @@ d := globalStore.Get(id, false)

if d == nil { return ErrNotFound } - _, err := NewImage(d, width, height).WriteTo(w) + _, err := NewImage(id, d, width, height).WriteTo(w) return err }

@@ -122,7 +122,7 @@ d := globalStore.Get(id, false)

if d == nil { return ErrNotFound } - _, err := NewAudio(d, lang).WriteTo(w) + _, err := NewAudio(id, d, lang).WriteTo(w) return err }
M image.goimage.go

@@ -1,4 +1,4 @@

-// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file.

@@ -28,32 +28,18 @@ *image.Paletted

numWidth int numHeight int dotSize int -} - -func randomPalette() color.Palette { - p := make([]color.Color, circleCount+1) - // Transparent color. - p[0] = color.RGBA{0xFF, 0xFF, 0xFF, 0x00} - // Primary color. - prim := color.RGBA{ - uint8(randIntn(129)), - uint8(randIntn(129)), - uint8(randIntn(129)), - 0xFF, - } - p[1] = prim - // Circle colors. - for i := 2; i <= circleCount; i++ { - p[i] = randomBrightness(prim, 255) - } - return p + rng siprng } // NewImage returns a new captcha image of the given width and height with the // given digits, where each digit must be in range 0-9. -func NewImage(digits []byte, width, height int) *Image { +func NewImage(id string, digits []byte, width, height int) *Image { m := new(Image) - m.Paletted = image.NewPaletted(image.Rect(0, 0, width, height), randomPalette()) + + // Initialize PRNG. + m.rng.Seed(deriveSeed(imageSeedPurpose, id, digits)) + + m.Paletted = image.NewPaletted(image.Rect(0, 0, width, height), m.getRandomPalette()) m.calculateSizes(width, height, len(digits)) // Randomly position captcha inside the image. maxx := width - (m.numWidth+m.dotSize)*len(digits) - m.dotSize

@@ -64,8 +50,8 @@ border = height / 5

} else { border = width / 5 } - x := randInt(border, maxx-border) - y := randInt(border, maxy-border) + x := m.rng.Int(border, maxx-border) + y := m.rng.Int(border, maxy-border) // Draw digits. for _, n := range digits { m.drawDigit(font[n], x, y)

@@ -74,12 +60,31 @@ }

// Draw strike-through line. m.strikeThrough() // Apply wave distortion. - m.distort(randFloat(5, 10), randFloat(100, 200)) + m.distort(m.rng.Float(5, 10), m.rng.Float(100, 200)) // Fill image with random circles. m.fillWithCircles(circleCount, m.dotSize) return m } +func (m *Image) getRandomPalette() color.Palette { + p := make([]color.Color, circleCount+1) + // Transparent color. + p[0] = color.RGBA{0xFF, 0xFF, 0xFF, 0x00} + // Primary color. + prim := color.RGBA{ + uint8(m.rng.Intn(129)), + uint8(m.rng.Intn(129)), + uint8(m.rng.Intn(129)), + 0xFF, + } + p[1] = prim + // Circle colors. + for i := 2; i <= circleCount; i++ { + p[i] = m.randomBrightness(prim, 255) + } + return p +} + // encodedPNG encodes an image to PNG and returns // the result as a byte slice. func (m *Image) encodedPNG() []byte {

@@ -167,34 +172,34 @@ func (m *Image) fillWithCircles(n, maxradius int) {

maxx := m.Bounds().Max.X maxy := m.Bounds().Max.Y for i := 0; i < n; i++ { - colorIdx := uint8(randInt(1, circleCount-1)) - r := randInt(1, maxradius) - m.drawCircle(randInt(r, maxx-r), randInt(r, maxy-r), r, colorIdx) + colorIdx := uint8(m.rng.Int(1, circleCount-1)) + r := m.rng.Int(1, maxradius) + m.drawCircle(m.rng.Int(r, maxx-r), m.rng.Int(r, maxy-r), r, colorIdx) } } func (m *Image) strikeThrough() { maxx := m.Bounds().Max.X maxy := m.Bounds().Max.Y - y := randInt(maxy/3, maxy-maxy/3) - amplitude := randFloat(5, 20) - period := randFloat(80, 180) + y := m.rng.Int(maxy/3, maxy-maxy/3) + amplitude := m.rng.Float(5, 20) + period := m.rng.Float(80, 180) dx := 2.0 * math.Pi / period for x := 0; x < maxx; x++ { xo := amplitude * math.Cos(float64(y)*dx) yo := amplitude * math.Sin(float64(x)*dx) for yn := 0; yn < m.dotSize; yn++ { - r := randInt(0, m.dotSize) + r := m.rng.Int(0, m.dotSize) m.drawCircle(x+int(xo), y+int(yo)+(yn*m.dotSize), r/2, 1) } } } func (m *Image) drawDigit(digit []byte, x, y int) { - skf := randFloat(-maxSkew, maxSkew) + skf := m.rng.Float(-maxSkew, maxSkew) xs := float64(x) r := m.dotSize / 2 - y += randInt(-r, r) + y += m.rng.Int(-r, r) for yo := 0; yo < fontHeight; yo++ { for xo := 0; xo < fontWidth; xo++ { if digit[yo*fontWidth+xo] != blackChar {

@@ -225,13 +230,13 @@ }

m.Paletted = newm } -func randomBrightness(c color.RGBA, max uint8) color.RGBA { +func (m *Image) randomBrightness(c color.RGBA, max uint8) color.RGBA { minc := min3(c.R, c.G, c.B) maxc := max3(c.R, c.G, c.B) if maxc > max { return c } - n := randIntn(int(max-maxc)) - int(minc) + n := m.rng.Intn(int(max-maxc)) - int(minc) return color.RGBA{ uint8(int(c.R) + n), uint8(int(c.G) + n),
M image_test.goimage_test.go

@@ -18,19 +18,21 @@

func BenchmarkNewImage(b *testing.B) { b.StopTimer() d := RandomDigits(DefaultLen) + id := randomId() b.StartTimer() for i := 0; i < b.N; i++ { - NewImage(d, StdWidth, StdHeight) + NewImage(id, d, StdWidth, StdHeight) } } func BenchmarkImageWriteTo(b *testing.B) { b.StopTimer() d := RandomDigits(DefaultLen) + id := randomId() b.StartTimer() counter := &byteCounter{} for i := 0; i < b.N; i++ { - img := NewImage(d, StdWidth, StdHeight) + img := NewImage(id, d, StdWidth, StdHeight) img.WriteTo(counter) b.SetBytes(counter.n) counter.n = 0
M random.gorandom.go

@@ -1,20 +1,58 @@

-// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package captcha import ( + "crypto/hmac" "crypto/rand" + "crypto/sha256" "io" ) // idLen is a length of captcha id string. +// (20 bytes of 62-letter alphabet give ~119 bits.) const idLen = 20 // idChars are characters allowed in captcha id. var idChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") +// rngKey is a secret key used to deterministically derive seeds for +// PRNGs used in image and audio. Generated once during initialization. +var rngKey [32]byte + +func init() { + if _, err := io.ReadFull(rand.Reader, rngKey[:]); err != nil { + panic("captcha: error reading random source: " + err.Error()) + } +} + +// Purposes for seed derivation. The goal is to make deterministic PRNG produce +// different outputs for images and audio by using different derived seeds. +const ( + imageSeedPurpose = 0x01 + audioSeedPurpose = 0x02 +) + +// deriveSeed returns a 16-byte PRNG seed from rngKey, purpose, id and digits. +// Same purpose, id and digits will result in the same derived seed for this +// instance of running application. +// +// out = HMAC(rngKey, purpose || id || 0x00 || digits) (cut to 16 bytes) +// +func deriveSeed(purpose byte, id string, digits []byte) (out [16]byte) { + var buf [sha256.Size]byte + h := hmac.New(sha256.New, rngKey[:]) + h.Write([]byte{purpose}) + io.WriteString(h, id) + h.Write([]byte{0}) + h.Write(digits) + sum := h.Sum(buf[:0]) + copy(out[:], sum) + return +} + // RandomDigits returns a byte slice of the given length containing // pseudorandom numbers in range 0-9. The slice can be used as a captcha // solution.

@@ -62,20 +100,3 @@ b[i] = idChars[c]

} return string(b) } - -var prng = &siprng{} - -// randIntn returns a pseudorandom non-negative int in range [0, n). -func randIntn(n int) int { - return prng.Intn(n) -} - -// randInt returns a pseudorandom int in range [from, to]. -func randInt(from, to int) int { - return prng.Intn(to+1-from) + from -} - -// randFloat returns a pseudorandom float64 in range [from, to]. -func randFloat(from, to float64) float64 { - return (to-from)*prng.Float64() + from -}
M server.goserver.go

@@ -60,7 +60,7 @@ d := globalStore.Get(id, false)

if d == nil { return ErrNotFound } - a := NewAudio(d, lang) + a := NewAudio(id, d, lang) if !download { w.Header().Set("Content-Type", "audio/x-wav") }
M siprng.gosiprng.go

@@ -1,15 +1,14 @@

+// Copyright 2014 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + package captcha -import ( - "crypto/rand" - "encoding/binary" - "io" - "sync" -) +import "encoding/binary" // siprng is PRNG based on SipHash-2-4. +// (Note: it's not safe to use a single siprng from multiple goroutines.) type siprng struct { - mu sync.Mutex k0, k1, ctr uint64 }

@@ -190,28 +189,34 @@

return v0 ^ v1 ^ v2 ^ v3 } -// rekey sets a new PRNG key, which is read from crypto/rand. -func (p *siprng) rekey() { - var k [16]byte - if _, err := io.ReadFull(rand.Reader, k[:]); err != nil { - panic(err.Error()) - } +// SetSeed sets a new secret seed for PRNG. +func (p *siprng) Seed(k [16]byte) { p.k0 = binary.LittleEndian.Uint64(k[0:8]) p.k1 = binary.LittleEndian.Uint64(k[8:16]) p.ctr = 1 } // Uint64 returns a new pseudorandom uint64. -// It rekeys PRNG on the first call and every 64 MB of generated data. func (p *siprng) Uint64() uint64 { - p.mu.Lock() - if p.ctr == 0 || p.ctr > 8*1024*1024 { - p.rekey() - } v := siphash(p.k0, p.k1, p.ctr) p.ctr++ - p.mu.Unlock() return v +} + +func (p *siprng) Bytes(n int) []byte { + // Since we don't have a buffer for generated bytes in siprng state, + // we just generate enough 8-byte blocks and then cut the result to the + // required length. Doing it this way, we lose generated bytes, and we + // don't get the strictly sequential deterministic output from PRNG: + // calling Uint64() and then Bytes(3) produces different output than + // when calling them in the reverse order, but for our applications + // this is OK. + numBlocks := (n + 8 - 1) / 8 + b := make([]byte, numBlocks*8) + for i := 0; i < len(b); i += 8 { + binary.LittleEndian.PutUint64(b[i:], p.Uint64()) + } + return b[:n] } func (p *siprng) Int63() int64 {

@@ -261,3 +266,13 @@ return v % n

} func (p *siprng) Float64() float64 { return float64(p.Int63()) / (1 << 63) } + +// Int returns a pseudorandom int in range [from, to]. +func (p *siprng) Int(from, to int) int { + return p.Intn(to+1-from) + from +} + +// Float returns a pseudorandom float64 in range [from, to]. +func (p *siprng) Float(from, to float64) float64 { + return (to-from)*p.Float64() + from +}
M siprng_test.gosiprng_test.go

@@ -1,6 +1,9 @@

package captcha -import "testing" +import ( + "bytes" + "testing" +) func TestSiphash(t *testing.T) { good := uint64(0xe849e8bb6ffe2567)

@@ -10,9 +13,41 @@ t.Fatalf("siphash: expected %x, got %x", good, cur)

} } +func TestSiprng(t *testing.T) { + m := make(map[uint64]interface{}) + var yes interface{} + r := siprng{} + r.Seed([16]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) + for i := 0; i < 100000; i++ { + v := r.Uint64() + if _, ok := m[v]; ok { + t.Errorf("siphash: collision on %d: %x", i, v) + } + m[v] = yes + } +} + +func TestSiprngBytes(t *testing.T) { + r := siprng{} + r.Seed([16]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) + x := r.Bytes(32) + if len(x) != 32 { + t.Fatalf("siphash: wrong length: expected 32, got %d", len(x)) + } + y := r.Bytes(32) + if bytes.Equal(x, y) { + t.Fatalf("siphash: stream repeats: %x = %x", x, y) + } + r.Seed([16]byte{}) + z := r.Bytes(32) + if bytes.Equal(z, x) { + t.Fatalf("siphash: outputs under different keys repeat: %x = %x", z, x) + } +} + func BenchmarkSiprng(b *testing.B) { b.SetBytes(8) - p := &siprng{}; + p := &siprng{} for i := 0; i < b.N; i++ { p.Uint64() }