A proof of work based captcha similar to friendly captcha, but lightweight, self-hosted and GPLv3 licensed.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
16 KiB

package main
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
"golang.org/x/crypto/scrypt"
)
// https://en.wikipedia.org/wiki/Scrypt
type ScryptParameters struct {
CPUAndMemoryCost int `json:"N"`
BlockSize int `json:"r"`
Paralellization int `json:"p"`
KeyLength int `json:"klen"`
}
type Challenge struct {
ScryptParameters
Preimage string `json:"i"`
Difficulty string `json:"d"`
DifficultyLevel int `json:"dl"`
}
var currentChallengesGeneration = map[string]int{}
var challenges = map[string]map[string]int{}
func main() {
var err error
batchSize := 1000
deprecateAfterBatches := 10
portNumber := 2370
scryptCPUAndMemoryCost := 4096
batchSizeEnv := os.ExpandEnv("$POW_CAPTCHA_BATCH_SIZE")
deprecateAfterBatchesEnv := os.ExpandEnv("$POW_CAPTCHA_DEPRECATE_AFTER_BATCHES")
portNumberEnv := os.ExpandEnv("$POW_CAPTCHA_LISTEN_PORT")
scryptCPUAndMemoryCostEnv := os.ExpandEnv("$POW_CAPTCHA_SCRYPT_CPU_AND_MEMORY_COST")
if batchSizeEnv != "" {
batchSize, err = strconv.Atoi(batchSizeEnv)
if err != nil {
panic(errors.Wrapf(err, "can't start the app because the POW_CAPTCHA_BATCH_SIZE '%s' can't be converted to an integer", batchSizeEnv))
}
}
if deprecateAfterBatchesEnv != "" {
deprecateAfterBatches, err = strconv.Atoi(deprecateAfterBatchesEnv)
if err != nil {
panic(errors.Wrapf(err, "can't start the app because the POW_CAPTCHA_DEPRECATE_AFTER_BATCHES '%s' can't be converted to an integer", deprecateAfterBatchesEnv))
}
}
if portNumberEnv != "" {
portNumber, err = strconv.Atoi(portNumberEnv)
if err != nil {
panic(errors.Wrapf(err, "can't start the app because the POW_CAPTCHA_LISTEN_PORT '%s' can't be converted to an integer", portNumberEnv))
}
}
if scryptCPUAndMemoryCostEnv != "" {
scryptCPUAndMemoryCost, err = strconv.Atoi(scryptCPUAndMemoryCostEnv)
if err != nil {
panic(errors.Wrapf(err, "can't start the app because the POW_CAPTCHA_SCRYPT_CPU_AND_MEMORY_COST '%s' can't be converted to an integer", scryptCPUAndMemoryCostEnv))
}
}
apiTokensFolder := locateAPITokensFolder()
adminAPIToken := os.ExpandEnv("$POW_CAPTCHA_ADMIN_API_TOKEN")
if adminAPIToken == "" {
panic(errors.New("can't start the app, the POW_CAPTCHA_ADMIN_API_TOKEN environment variable is required"))
}
scryptParameters := ScryptParameters{
CPUAndMemoryCost: scryptCPUAndMemoryCost,
BlockSize: 8,
Paralellization: 1,
KeyLength: 16,
}
requireMethod := func(method string) func(http.ResponseWriter, *http.Request) bool {
return func(responseWriter http.ResponseWriter, request *http.Request) bool {
if request.Method != method {
responseWriter.Header().Set("Allow", method)
http.Error(responseWriter, fmt.Sprintf("405 Method Not Allowed, try %s", method), http.StatusMethodNotAllowed)
return true
}
return false
}
}
requireAdmin := func(responseWriter http.ResponseWriter, request *http.Request) bool {
if request.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", adminAPIToken) {
http.Error(responseWriter, "401 Unauthorized", http.StatusUnauthorized)
return true
}
return false
}
requireToken := func(responseWriter http.ResponseWriter, request *http.Request) bool {
authorizationHeader := request.Header.Get("Authorization")
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
http.Error(responseWriter, "401 Unauthorized: Authorization header is required and must start with 'Bearer '", http.StatusUnauthorized)
return true
}
token := strings.TrimPrefix(authorizationHeader, "Bearer ")
if token == "" {
http.Error(responseWriter, "401 Unauthorized: Authorization Bearer token is required", http.StatusUnauthorized)
return true
}
if !regexp.MustCompile("^[0-9a-f]{32}$").MatchString(token) {
errorMsg := fmt.Sprintf("401 Unauthorized: Authorization Bearer token '%s' must be a 32 character hex string", token)
http.Error(responseWriter, errorMsg, http.StatusUnauthorized)
return true
}
fileInfos, err := ioutil.ReadDir(apiTokensFolder)
if err != nil {
log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
foundToken := false
for _, fileInfo := range fileInfos {
if strings.HasPrefix(fileInfo.Name(), token) {
foundToken = true
break
}
}
if !foundToken {
errorMsg := fmt.Sprintf("401 Unauthorized: Authorization Bearer token '%s' was in the right format, but it was unrecognized", token)
http.Error(responseWriter, errorMsg, http.StatusUnauthorized)
return true
}
return false
}
myHTTPHandleFunc("/Tokens", requireMethod("GET"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool {
fileInfos, err := ioutil.ReadDir(apiTokensFolder)
if err != nil {
log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
output := []string{}
for _, fileInfo := range fileInfos {
filenameSplit := strings.Split(fileInfo.Name(), "_")
if len(filenameSplit) == 2 {
filepath := path.Join(apiTokensFolder, fileInfo.Name())
content, err := ioutil.ReadFile(filepath)
if err != nil {
log.Printf("failed to read the token file (%s): %v", filepath, err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
contentInt64, err := strconv.ParseInt(string(content), 10, 64)
timestampString := time.Unix(contentInt64, 0).UTC().Format(time.RFC3339)
output = append(output, fmt.Sprintf("%s,%s,%d,%s", filenameSplit[0], filenameSplit[1], contentInt64, timestampString))
}
}
responseWriter.Header().Set("Content-Type", "text/plain")
responseWriter.Write([]byte(strings.Join(output, "\n")))
return true
})
myHTTPHandleFunc("/Tokens/Create", requireMethod("POST"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool {
name := request.URL.Query().Get("name")
if name == "" {
http.Error(responseWriter, "400 Bad Request: url param ?name=<string> is required", http.StatusBadRequest)
return true
}
// we use underscore as a syntax character in the filename, so we have to remove it from the user-inputted name
name = strings.ReplaceAll(name, "_", "-")
// let's also remove any sort of funky or path-related characters
name = strings.ReplaceAll(name, "*", "")
name = strings.ReplaceAll(name, "?", "")
name = strings.ReplaceAll(name, "/", "-")
name = strings.ReplaceAll(name, "\\", "-")
name = strings.ReplaceAll(name, ".", "-")
tokenBytes := make([]byte, 16)
rand.Read(tokenBytes)
ioutil.WriteFile(
path.Join(apiTokensFolder, fmt.Sprintf("%x_%s", tokenBytes, name)),
[]byte(fmt.Sprintf("%d", time.Now().Unix())),
0644,
)
fmt.Fprintf(responseWriter, "%x", tokenBytes)
return true
})
myHTTPHandleFunc("/Tokens/Revoke", requireMethod("POST"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool {
token := request.URL.Query().Get("token")
if token == "" {
http.Error(responseWriter, "400 Bad Request: url param ?token=<string> is required", http.StatusBadRequest)
return true
}
if !regexp.MustCompile("^[0-9a-f]{32}$").MatchString(token) {
errorMsg := fmt.Sprintf("400 Bad Request: url param ?token=%s must be a 32 character hex string", token)
http.Error(responseWriter, errorMsg, http.StatusBadRequest)
return true
}
fileInfos, err := ioutil.ReadDir(apiTokensFolder)
if err != nil {
log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
for _, fileInfo := range fileInfos {
if strings.HasPrefix(fileInfo.Name(), token) {
os.Remove(path.Join(apiTokensFolder, fileInfo.Name()))
}
}
responseWriter.Write([]byte("Revoked"))
return true
})
myHTTPHandleFunc("/GetChallenges", requireMethod("POST"), requireToken, func(responseWriter http.ResponseWriter, request *http.Request) bool {
// requireToken already validated the API Token, so we can just do this:
token := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ")
if _, has := currentChallengesGeneration[token]; !has {
currentChallengesGeneration[token] = 0
}
if _, has := challenges[token]; !has {
challenges[token] = map[string]int{}
}
currentChallengesGeneration[token]++
requestQuery := request.URL.Query()
difficultyLevelString := requestQuery.Get("difficultyLevel")
difficultyLevel, err := strconv.Atoi(difficultyLevelString)
if err != nil {
errorMessage := fmt.Sprintf(
"400 url param ?difficultyLevel=%s value could not be converted to an integer",
difficultyLevelString,
)
http.Error(responseWriter, errorMessage, http.StatusBadRequest)
return true
}
toReturn := make([]string, batchSize)
for i := 0; i < batchSize; i++ {
preimageBytes := make([]byte, 8)
_, err := rand.Read(preimageBytes)
if err != nil {
log.Printf("read random bytes failed: %v", err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
preimage := base64.StdEncoding.EncodeToString(preimageBytes)
difficultyBytes := make([]byte, int(math.Ceil(float64(difficultyLevel)/float64(8))))
for j := 0; j < len(difficultyBytes); j++ {
difficultyByte := byte(0)
for k := 0; k < 8; k++ {
currentBitIndex := (len(difficultyBytes) * 8) - (j*8 + k)
if currentBitIndex > difficultyLevel {
difficultyByte = difficultyByte | 1<<k
}
}
difficultyBytes[j] = difficultyByte
}
difficulty := hex.EncodeToString(difficultyBytes)
challenge := Challenge{
Preimage: preimage,
Difficulty: difficulty,
DifficultyLevel: difficultyLevel,
}
challenge.CPUAndMemoryCost = scryptParameters.CPUAndMemoryCost
challenge.BlockSize = scryptParameters.BlockSize
challenge.Paralellization = scryptParameters.Paralellization
challenge.KeyLength = scryptParameters.KeyLength
challengeBytes, err := json.Marshal(challenge)
if err != nil {
log.Printf("serialize challenge as json failed: %v", err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
challengeBase64 := base64.StdEncoding.EncodeToString(challengeBytes)
challenges[token][challengeBase64] = currentChallengesGeneration[token]
toReturn[i] = challengeBase64
}
toRemove := []string{}
for k, generation := range challenges[token] {
if generation+deprecateAfterBatches < currentChallengesGeneration[token] {
toRemove = append(toRemove, k)
}
}
for _, k := range toRemove {
delete(challenges[token], k)
}
responseBytes, err := json.Marshal(toReturn)
if err != nil {
log.Printf("json marshal failed: %v", err)
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
responseWriter.Write(responseBytes)
return true
})
myHTTPHandleFunc("/Verify", requireMethod("POST"), requireToken, func(responseWriter http.ResponseWriter, request *http.Request) bool {
// requireToken already validated the API Token, so we can just do this:
token := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ")
requestQuery := request.URL.Query()
challengeBase64 := requestQuery.Get("challenge")
nonceHex := requestQuery.Get("nonce")
_, hasAnyChallenges := challenges[token]
hasChallenge := false
if hasAnyChallenges {
_, hasChallenge = challenges[token][challengeBase64]
}
if !hasChallenge {
errorMessage := fmt.Sprintf("404 challenge given by url param ?challenge=%s was not found", challengeBase64)
http.Error(responseWriter, errorMessage, http.StatusNotFound)
return true
}
delete(challenges[token], challengeBase64)
nonceBuffer := make([]byte, 8)
bytesWritten, err := hex.Decode(nonceBuffer, []byte(nonceHex))
if nonceHex == "" || err != nil {
errorMessage := fmt.Sprintf("400 bad request: nonce given by url param ?nonce=%s could not be hex decoded", nonceHex)
http.Error(responseWriter, errorMessage, http.StatusBadRequest)
return true
}
nonceBytes := nonceBuffer[:bytesWritten]
challengeJSON, err := base64.StdEncoding.DecodeString(challengeBase64)
if err != nil {
log.Printf("challenge %s couldn't be parsed: %v\n", challengeBase64, err)
http.Error(responseWriter, "500 challenge couldn't be decoded", http.StatusInternalServerError)
return true
}
var challenge Challenge
err = json.Unmarshal([]byte(challengeJSON), &challenge)
if err != nil {
log.Printf("challenge %s (%s) couldn't be parsed: %v\n", string(challengeJSON), challengeBase64, err)
http.Error(responseWriter, "500 challenge couldn't be parsed", http.StatusInternalServerError)
return true
}
preimageBytes := make([]byte, 8)
n, err := base64.StdEncoding.Decode(preimageBytes, []byte(challenge.Preimage))
if n != 8 || err != nil {
log.Printf("invalid preimage %s: %v\n", challenge.Preimage, err)
http.Error(responseWriter, "500 invalid preimage", http.StatusInternalServerError)
return true
}
hash, err := scrypt.Key(
nonceBytes,
preimageBytes,
challenge.CPUAndMemoryCost,
challenge.BlockSize,
challenge.Paralellization,
challenge.KeyLength,
)
if err != nil {
log.Printf("scrypt returned error: %v\n", err)
http.Error(responseWriter, "500 scrypt returned error", http.StatusInternalServerError)
return true
}
hashHex := hex.EncodeToString(hash)
if hashHex[len(hashHex)-len(challenge.Difficulty):] > challenge.Difficulty {
errorMessage := fmt.Sprintf(
"400 bad request: nonce given by url param ?nonce=%s did not result in a hash that meets the required difficulty",
nonceHex,
)
http.Error(responseWriter, errorMessage, http.StatusBadRequest)
return true
}
responseWriter.WriteHeader(200)
responseWriter.Write([]byte("OK"))
return true
})
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
log.Printf("💥 PoW! Captcha server listening on port %d", portNumber)
err = http.ListenAndServe(fmt.Sprintf(":%d", portNumber), nil)
// if got this far it means server crashed!
panic(err)
}
func myHTTPHandleFunc(path string, stack ...func(http.ResponseWriter, *http.Request) bool) {
http.HandleFunc(path, func(responseWriter http.ResponseWriter, request *http.Request) {
for _, handler := range stack {
if handler(responseWriter, request) {
break
}
}
})
}
func locateAPITokensFolder() string {
workingDirectory, err := os.Getwd()
if err != nil {
log.Fatalf("locateAPITokensFolder(): can't os.Getwd(): %v", err)
}
executableDirectory, err := getCurrentExecDir()
if err != nil {
log.Fatalf("locateAPITokensFolder(): can't getCurrentExecDir(): %v", err)
}
nextToExecutable := filepath.Join(executableDirectory, "PoW_Captcha_API_Tokens")
inWorkingDirectory := filepath.Join(workingDirectory, "PoW_Captcha_API_Tokens")
nextToExecutableStat, err := os.Stat(nextToExecutable)
foundKeysNextToExecutable := err == nil && nextToExecutableStat.IsDir()
inWorkingDirectoryStat, err := os.Stat(inWorkingDirectory)
foundKeysInWorkingDirectory := err == nil && inWorkingDirectoryStat.IsDir()
if foundKeysNextToExecutable && foundKeysInWorkingDirectory && workingDirectory != executableDirectory {
log.Fatalf(`locateAPITokensFolder(): Something went wrong with your installation,
I found two PoW_Captcha_API_Tokens folders and I'm not sure which one to use.
One of them is located at %s
and the other is at %s`, inWorkingDirectory, nextToExecutable)
}
if foundKeysInWorkingDirectory {
return inWorkingDirectory
} else if foundKeysNextToExecutable {
return nextToExecutable
}
log.Fatalf(`locateAPITokensFolder(): I didn't find a PoW_Captcha_API_Tokens folder
in the current working directory (in %s) or next to the executable (in %s)`, workingDirectory, executableDirectory)
return ""
}
func getCurrentExecDir() (dir string, err error) {
path, err := exec.LookPath(os.Args[0])
if err != nil {
fmt.Printf("exec.LookPath(%s) returned %s\n", os.Args[0], err)
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
fmt.Printf("filepath.Abs(%s) returned %s\n", path, err)
return "", err
}
dir = filepath.Dir(absPath)
return dir, nil
}