add config and admin api, make tokens required

This commit is contained in:
forest 2021-02-25 16:55:54 -06:00
parent a05b419fa3
commit 1d8ce4cecb
6 changed files with 485 additions and 92 deletions

4
.gitignore vendored
View File

@ -1,3 +1,5 @@
wasm_build/node_modules
wasm_build/scrypt-wasm
wasm_build/scrypt-wasm
PoW_Captcha_API_Tokens/*
!PoW_Captcha_API_Tokens/README.md

View File

@ -0,0 +1,5 @@
# PoW_Captcha_API_Tokens folder
💥PoW! Captcha will store API tokens here. You may place this folder either in the current working directory from which the application is started, or you may place it next to the application binary.
If you run 💥PoW! Captcha in a linux container, you will want to mount this folder to a persistent volume so you don't lose your API tokens when you upgrade the container to a new image!

148
README.md
View File

@ -4,7 +4,6 @@ A proof of work based captcha similar to [friendly captcha](https://github.com/F
![screencast](readme/screencast.gif)
# How it works
This application was designed to be a drop-in replacement for ReCaptcha by Google. It works pretty much the same way;
@ -36,10 +35,56 @@ If you want to read more or see a concrete example, see [What is Proof of Work?
This diagram was created with https://app.diagrams.net/.
To edit it, download the <a download href="readme/sequence.drawio">diagram file</a> and edit it with the https://app.diagrams.net/ web application, or you may run the application from [source](https://github.com/jgraph/drawio) if you wish.
# HTTP API
# Configuring
💥PoW! Captcha gets all of its configuration from environment variables.
#### `POW_CAPTCHA_ADMIN_API_TOKEN`
⚠️ **REQUIRED**
This token allows control of the Admin API & allows the bearer to create, list, and revoke application tokens.
----
#### `POW_CAPTCHA_BATCH_SIZE`
💬 *OPTIONAL* default value is 1000
How many captcha challenges to return at once.
----
#### `POW_CAPTCHA_DEPRECATE_AFTER_BATCHES`
💬 *OPTIONAL* default value is 10
How many batches old captcha challenges can be before being dropped.
----
#### `POW_CAPTCHA_LISTEN_PORT`
💬 *OPTIONAL* default value is 2730
Which TCP port should the server listen on.
----
#### `POW_CAPTCHA_SCRYPT_CPU_AND_MEMORY_COST`
💬 *OPTIONAL* default value is 4096
Allows you to tweak how difficult each individual hash in the proof of work will be.
----
# HTTP Captcha API
#### `POST /GetChallenges?difficultyLevel=<int>`
Required Header: `Authorization: Bearer <api-token>`
Return type: `application/json`
`GetChallenges` returns a JSON array of 1000 strings. The Captcha server will remember each one of these challeges until it is
@ -52,6 +97,8 @@ The recommended value is 8. A difficulty of 8 will be solved quickly by a laptop
#### `POST /Verify?challenge=<string>&nonce=<string>`
Required Header: `Authorization: Bearer <api-token>`
Return type: `text/plain` (error/status messages only)
`Verify` returns HTTP 200 OK only if all of the following are true:
@ -77,6 +124,32 @@ Files:
You only need to include `captcha.js` in your page, it will pull in the other files automatically.
See below for a more detailed implementation walkthrough.
## HTTP Admin API
#### `GET /Tokens`
Required Header: `Authorization: Bearer <admin-api-token>`
Return type: `text/plain`
Lists all existing api tokens in CSV format, including the token itself, the name, and when it was created.
#### `POST /Tokens/Create?name=<string>`
Required Header: `Authorization: Bearer <admin-api-token>`
Return type: `text/plain`
Creates a new given API token with the given name and returns the token as a plain text hexadecimal string.
#### `POST /Tokens/Revoke?token=<api-token>`
Required Header: `Authorization: Bearer <admin-api-token>`
Return type: `text/plain` (error/status messages only)
Revokes an existing API token.
# HTML DOM API
@ -133,19 +206,59 @@ in as simple of a fashion as possible.
If you wish to run the example app, you will have to run both the 💥PoW! Captcha server and the example app server.
The easiest way to do this would probably be to open two separate terminal windows.
The easiest way to do this would probably be to open two separate terminal windows or tabs and run each app in its own terminal.
`terminal 1`
#### `terminal 1`
```
forest@thingpad:~/Desktop/git/sequentialread-pow-captcha$ go run main.go
2021/02/25 00:27:12 💥 PoW! Captcha server listening on port 2370
panic: can't start the app, the POW_CAPTCHA_ADMIN_API_TOKEN environment variable is required
goroutine 1 [running]:
main.main()
/home/forest/Desktop/git/sequentialread-pow-captcha/main.go:84 +0xf45
exit status 2
```
As you can see, the server requires an admin API token to be set. This is the token we will use authenticate and create
individual tokens for different apps or different people who all might want to use the captcha server.
Once we provide this admin API token environment variable, it will run just fine:
```
forest@thingpad:~/Desktop/git/sequentialread-pow-captcha$ POW_CAPTCHA_ADMIN_API_TOKEN="example_admin" go run main.go
2021/02/25 16:24:00 💥 PoW! Captcha server listening on port 2370
```
`terminal 2`
Now let's try to launch the example Todo List application:
#### `terminal 2`
```
forest@thingpad:~/Desktop/git/sequentialread-pow-captcha$ cd example/
forest@thingpad:~/Desktop/git/sequentialread-pow-captcha/example$ go run main.go
2021/02/25 01:15:17 📋 Todo List example application listening on port 8080
panic: can't start the app, the CAPTCHA_API_TOKEN environment variable is required
goroutine 1 [running]:
main.main()
/home/forest/Desktop/git/sequentialread-pow-captcha/example/main.go:40 +0x488
exit status 2
```
It's a similar story for the example app, except this time we can't just make up any old token, we have to ask the captcha server to generate a new API token for the example app. I will do this by manually sending it an http request with `curl`:
```
$ curl -X POST -H "Authorization: Bearer example_admin" http://localhost:2370/Tokens/Create
400 Bad Request: url param ?name=<string> is required
$ curl -X POST -H "Authorization: Bearer example_admin" http://localhost:2370/Tokens/Create?name=todo-list
b804f221e8a9053b2e6e89de83c5d7a4
```
Now we can use this token to start the example Todo List app:
```
$ CAPTCHA_API_TOKEN="b804f221e8a9053b2e6e89de83c5d7a4" go run main.go
2021/02/25 16:38:32 📋 Todo List example application listening on port 8080
```
Then, you should be able to visit the example Todo List application in the browser at http://localhost:8080.
@ -252,6 +365,21 @@ property. It will then validate each element to make sure it also has the `data-
When the Proof of Work finishes, `captcha.js` will call the function specified by `data-sqr-captcha-callback`, passing the winning nonce as the first argument, or throw an error if that function is not defined.
💬 **INFO** that the element with the `sqr-captcha` data properties also has a class that *WE* defined, called `captcha-container`.
This class has a very small font size. When I was designing the css for the captcha element, I made everything scale based on the font size (by using `em`). But because the page I was testing it on had a small font by default, I accidentally made it huge when it is rendered on a default HTML page. So for now you will want to make the font size of the element which contains it fairly small.
```
<style>
.captcha-container {
margin-top: 1em;
font-size: 10px;
}
...
</style>
```
I think that concludes the walkthrough! In the Todo App, as soon as `captcha.js` calls `myCaptchaCallback`, the form will be completely filled out and the submit button will be enabled. When the form is posted, the browser will make a `POST` request to the server, and the server logic we already discussed will take over, closing the loop.
# Implementation Details for Developers
@ -264,8 +392,10 @@ I tried two different implementations of the scrypt hash function, one from the
| hardware | sjcl,single thread | sjcl,multi-thread | WASM,multi-thread |
| :------------- | :------------- | :----------: | -----------: |
| Laptop | 1-2 h/s | ~5 h/s | ~70 h/s |
| Phone | not tested | not tested | ~12 h/s |
| Lenovo T480s | 1-2 h/s | ~5 h/s | ~70 h/s |
| Motorolla G7 | not tested | not tested | ~12 h/s |
| Macbook Air 2018 | not tested | not tested | ~ 32h/s |
| Google Pixel 3a | not tested | not tested | ~ 24h/s |
I had some trouble getting the WASM module loaded properly inside the WebWorkers. In my production environment, the web application server and the captcha server are running on separate subdomains, so I was getting cross-origin security violation issues.

View File

@ -9,6 +9,7 @@ import (
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
@ -34,13 +35,18 @@ func main() {
Timeout: time.Second * time.Duration(5),
}
apiToken := os.ExpandEnv("$CAPTCHA_API_TOKEN")
if apiToken == "" {
panic(errors.New("can't start the app, the CAPTCHA_API_TOKEN environment variable is required"))
}
var err error
captchaAPIURL, err = url.Parse("http://localhost:2370")
if err != nil {
panic(errors.New("can't start the app because can't parse captchaAPIURL"))
}
err = loadCaptchaChallenges()
err = loadCaptchaChallenges(apiToken)
if err != nil {
panic(errors.Wrap(err, "can't start the app because could not loadCaptchaChallenges():"))
}
@ -59,7 +65,7 @@ func main() {
// and if not, return HTTP 400 Bad Request
err := request.ParseForm()
if err == nil {
err = validateCaptcha(request.Form.Get("challenge"), request.Form.Get("nonce"))
err = validateCaptcha(apiToken, request.Form.Get("challenge"), request.Form.Get("nonce"))
}
if err != nil {
@ -76,12 +82,12 @@ func main() {
// note that in a real application in production, you would want to use a lock or mutex to ensure that
// this only happens once if lots of requests come in at the same time
if len(captchaChallenges) > 0 && len(captchaChallenges) < 5 {
go loadCaptchaChallenges()
go loadCaptchaChallenges(apiToken)
}
// if we somehow completely ran out of challenges, load more synchronously
if captchaChallenges == nil || len(captchaChallenges) == 0 {
err = loadCaptchaChallenges()
err = loadCaptchaChallenges(apiToken)
if err != nil {
log.Printf("loading captcha challenges failed: %v", err)
responseWriter.WriteHeader(500)
@ -146,7 +152,7 @@ func renderPageTemplate(challenge string) ([]byte, error) {
return outputBuffer.Bytes(), nil
}
func loadCaptchaChallenges() error {
func loadCaptchaChallenges(apiToken string) error {
query := url.Values{}
query.Add("difficultyLevel", strconv.Itoa(captchaDifficultyLevel))
@ -159,6 +165,7 @@ func loadCaptchaChallenges() error {
}
captchaRequest, err := http.NewRequest("POST", loadURL.String(), nil)
captchaRequest.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken))
if err != nil {
return err
}
@ -192,7 +199,7 @@ func loadCaptchaChallenges() error {
return nil
}
func validateCaptcha(challenge, nonce string) error {
func validateCaptcha(apiToken, challenge, nonce string) error {
query := url.Values{}
query.Add("challenge", challenge)
query.Add("nonce", nonce)
@ -205,6 +212,7 @@ func validateCaptcha(challenge, nonce string) error {
}
captchaRequest, err := http.NewRequest("POST", verifyURL.String(), nil)
captchaRequest.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken))
if err != nil {
return err
}

395
main.go
View File

@ -6,19 +6,23 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/crypto/scrypt"
)
const batchSize = 1000
const deprecateAfterBatches = 10
const portNumber = 2370
const scryptCPUAndMemoryCost = 4096
// https://en.wikipedia.org/wiki/Scrypt
type ScryptParameters struct {
CPUAndMemoryCost int `json:"N"`
@ -34,11 +38,52 @@ type Challenge struct {
DifficultyLevel int `json:"dl"`
}
var currentChallengesGeneration = 0
var challenges = map[string]int{}
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,
@ -46,28 +91,175 @@ func main() {
KeyLength: 16,
}
http.HandleFunc("/GetChallenges", func(responseWriter http.ResponseWriter, request *http.Request) {
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
}
}
if request.Method != "POST" {
responseWriter.Header().Set("Allow", "POST")
http.Error(responseWriter, "405 Method Not Allowed, try POST", http.StatusMethodNotAllowed)
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
}
currentChallengesGeneration++
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 {
http.Error(
responseWriter,
fmt.Sprintf(
"400 url param ?difficultyLevel=%s value could not be converted to an integer",
difficultyLevelString,
),
http.StatusInternalServerError,
errorMessage := fmt.Sprintf(
"400 url param ?difficultyLevel=%s value could not be converted to an integer",
difficultyLevelString,
)
return
http.Error(responseWriter, errorMessage, http.StatusBadRequest)
return true
}
toReturn := make([]string, batchSize)
@ -75,9 +267,9 @@ func main() {
preimageBytes := make([]byte, 8)
_, err := rand.Read(preimageBytes)
if err != nil {
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
log.Printf("read random bytes failed: %v", err)
return
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))))
@ -106,96 +298,90 @@ func main() {
challengeBytes, err := json.Marshal(challenge)
if err != nil {
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
log.Printf("serialize challenge as json failed: %v", err)
return
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
challengeBase64 := base64.StdEncoding.EncodeToString(challengeBytes)
challenges[challengeBase64] = currentChallengesGeneration
challenges[token][challengeBase64] = currentChallengesGeneration[token]
toReturn[i] = challengeBase64
}
toRemove := []string{}
for k, generation := range challenges {
if generation+deprecateAfterBatches < currentChallengesGeneration {
for k, generation := range challenges[token] {
if generation+deprecateAfterBatches < currentChallengesGeneration[token] {
toRemove = append(toRemove, k)
}
}
for _, k := range toRemove {
delete(challenges, k)
delete(challenges[token], k)
}
responseBytes, err := json.Marshal(toReturn)
if err != nil {
http.Error(responseWriter, "500 internal doodoo error", http.StatusInternalServerError)
log.Printf("json marshal failed: %v", err)
return
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return true
}
responseWriter.Write(responseBytes)
return true
})
http.HandleFunc("/Verify", func(responseWriter http.ResponseWriter, request *http.Request) {
myHTTPHandleFunc("/Verify", requireMethod("POST"), requireToken, func(responseWriter http.ResponseWriter, request *http.Request) bool {
if request.Method != "POST" {
responseWriter.Header().Set("Allow", "POST")
http.Error(responseWriter, "405 Method Not Allowed, try POST", http.StatusMethodNotAllowed)
}
// 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")
if _, has := challenges[challengeBase64]; !has {
http.Error(
responseWriter,
fmt.Sprintf(
"404 challenge given by url param ?challenge=%s was not found",
challengeBase64,
),
http.StatusNotFound,
)
return
_, hasAnyChallenges := challenges[token]
hasChallenge := false
if hasAnyChallenges {
_, hasChallenge = challenges[token][challengeBase64]
}
delete(challenges, 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 {
http.Error(
responseWriter,
fmt.Sprintf(
"400 bad request: nonce given by url param ?nonce=%s could not be hex decoded",
nonceHex,
),
http.StatusBadRequest,
)
return
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)
challengeJSON, err := base64.StdEncoding.DecodeString(challengeBase64)
if err != nil {
http.Error(responseWriter, "500 challenge couldn't be decoded", http.StatusInternalServerError)
log.Printf("challenge %s couldn't be parsed: %v\n", challengeBase64, err)
return
http.Error(responseWriter, "500 challenge couldn't be decoded", http.StatusInternalServerError)
return true
}
var challenge Challenge
err = json.Unmarshal([]byte(challengeJson), &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)
log.Printf("challenge %s (%s) couldn't be parsed: %v\n", challengeJson, challenge, err)
return
return true
}
preimageBytes := make([]byte, 8)
n, err := base64.StdEncoding.Decode(preimageBytes, []byte(challenge.Preimage))
if n != 8 || err != nil {
http.Error(responseWriter, "500 invalid preimage", http.StatusInternalServerError)
log.Printf("invalid preimage %s: %v\n", challenge.Preimage, err)
return
http.Error(responseWriter, "500 invalid preimage", http.StatusInternalServerError)
return true
}
hash, err := scrypt.Key(
@ -208,34 +394,95 @@ func main() {
)
if err != nil {
log.Printf("scrypt returned error: %v\n", err)
http.Error(responseWriter, "500 scrypt returned error", http.StatusInternalServerError)
log.Printf("scrypt returned error: %v\n", challengeJson, challenge, err)
return
return true
}
hashHex := hex.EncodeToString(hash)
if hashHex[len(hashHex)-len(challenge.Difficulty):] > challenge.Difficulty {
http.Error(
responseWriter,
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.StatusBadRequest,
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,
)
return
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)
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
}

View File

@ -266,7 +266,8 @@
"a",
{
"class": "sqr-captcha-link",
"href": "https://git.sequentialread.com/forest/sequentialread-pow-captcha"
"href": "https://git.sequentialread.com/forest/sequentialread-pow-captcha",
"target": "_blank"
},
"💥PoW! "
);
@ -277,7 +278,7 @@
createElement(
description,
"a",
{"href": "https://en.wikipedia.org/wiki/Proof_of_work"},
{ "href": "https://en.wikipedia.org/wiki/Proof_of_work", "target": "_blank" },
"Proof of Work"
);
appendFragment(description, ". ");