mirror of
https://github.com/sequentialread/pow-captcha.git
synced 2025-03-30 15:08:29 +00:00
fleshing out the app, creating the README & example app
This commit is contained in:
parent
d498d28677
commit
51d6c51b28
239
README.md
239
README.md
@ -1,8 +1,241 @@
|
||||
# sequentialread-pow-captcha
|
||||
# 💥PoW! Captcha
|
||||
|
||||
A proof of work based captcha similar to [friendly captcha](https://github.com/FriendlyCaptcha/friendly-challenge), but lightweight and FLOSS
|
||||
A proof of work based captcha similar to [friendly captcha](https://github.com/FriendlyCaptcha/friendly-challenge), but lightweight, self-hosted and Affero GPL licensed.
|
||||
|
||||

|
||||
|
||||
|
||||
# How it works
|
||||
|
||||
This application was designed to be a drop-in replacement for ReCaptcha by Google. It works pretty much the same way;
|
||||
|
||||
1. Your web application requests a captcha (in this case, a batch of captchas) from the captcha HTTP API
|
||||
2. Your web application displays an HTML page which includes a form, and passes the captcha data to the form
|
||||
3. The HTML page includes the JavaScript part of the Captcha app, this JavaScript draws the Captcha on the page
|
||||
4. When the Captcha is complete, its JavaScript will fire off a callback to your JavaScript (usually used to enable the submit button on the form)
|
||||
5. When the form is submitted, your web application submites the captcha result to the captcha HTTP API for validation
|
||||
|
||||
# Sequence diagram
|
||||
|
||||

|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
#### `POST /GetChallenges?difficultyLevel=<int>`
|
||||
|
||||
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
|
||||
restarted, or until GetChallenges has been called 10 more times. Each challenge can only be used once.
|
||||
|
||||
The difficultyLevel parameter specifies how many bits of difficulty the challenges should have.
|
||||
Each time you increase the difficultyLevel by 1, it doubles the amount of time the Proof of Work will take on average.
|
||||
The recommended value is 8. A difficulty of 8 will be solved quickly by a laptop or desktop computer, and solved within 60 seconds or so by a cell phone.
|
||||
|
||||
|
||||
#### `POST /Verify?challenge=<string>&nonce=<string>`
|
||||
|
||||
Return type: `text/plain` (error/status messages only)
|
||||
|
||||
`Verify` returns HTTP 200 OK only if all of the following are true:
|
||||
|
||||
- This challenge was returned by `GetChallenges`.
|
||||
- `GetChallenges` hasn't been called 10 or more times since this challenge was originally returned.
|
||||
- Verify has not been called on this challenge before.
|
||||
- The provided hexadecimal nonce solves the challenge.
|
||||
|
||||
Otherwise it returns 404, 400, or 500.
|
||||
|
||||
|
||||
#### `GET /static/<filename>`
|
||||
|
||||
Return type: depends on file
|
||||
|
||||
Files:
|
||||
|
||||
- captcha.js
|
||||
- captcha.css
|
||||
- proofOfWorker.js
|
||||
|
||||
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.
|
||||
|
||||
|
||||
# HTML DOM API
|
||||
|
||||
In order to set up 💥PoW! Captcha on your page, you just need to load/include `captcha.js` and one or more html elements
|
||||
with all 3 of the following properties:
|
||||
|
||||
#### `data-sqr-captcha-url`
|
||||
|
||||
This is the base url from which `captcha.js` will attempt to load additional resources `captcha.css` and `proofOfWorker.js`.
|
||||
|
||||
#### `data-sqr-captcha-challenge`
|
||||
|
||||
This is one of the challenge strings returned by `GetChallenges`. It must be unique, each challenge can only be used once.
|
||||
|
||||
#### `data-sqr-captcha-callback`
|
||||
|
||||
This is the name of a function in the global namespace which will be called & passed the winning nonce once the Proof of Work
|
||||
is completed. So, for example, if you had:
|
||||
|
||||
`<div ... data-sqr-captcha-callback="myCallbackFunction"></div>`
|
||||
|
||||
Then you would provide your callback like so:
|
||||
|
||||
```
|
||||
<script>
|
||||
window.myCallbackFunction = function(nonce) {
|
||||
...
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
You may also nest the callback inside object(s) if you wish:
|
||||
|
||||
`<div ... data-sqr-captcha-callback="myApp.myCallbackFunction"></div>`
|
||||
|
||||
```
|
||||
<script>
|
||||
window.myApp = {
|
||||
myCallbackFunction: function(nonce) {
|
||||
...
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
When `captcha.js` runs, if it finds an element with `data-sqr-captcha-challenge` & `data-sqr-captcha-callback`, but the callback function is not defined yet, it will print a warning message. If the callback is still not defined when the Proof of Work is completed, it will throw an error.
|
||||
|
||||
⚠️ **NOTE** that the element with the `sqr-captcha` data properties **MUST** be placed **inside a form element**. This is required, to allow the captcha to know which input elements it needs to trigger on. We only want the captcha to trigger when the user actually intends to submit the form; otherwise we are wasting a lot of their CPU cycles for no reason!
|
||||
|
||||
# Running the example app
|
||||
|
||||
The `example` folder in this repository contains an example app that demonstrates how to implement the 💥PoW! Captcha
|
||||
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.
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
Then, you should be able to visit the example Todo List application in the browser at http://localhost:8080.
|
||||
|
||||
# Implementation walkthrough via example app
|
||||
|
||||
Lets walk through how example app works and how it integrates the 💥PoW! Captcha.
|
||||
|
||||
The Todo List app has two pieces of configuration related to the captcha: the url and the difficulty.
|
||||
Currently these are hardcoded into the Todo List app's code.
|
||||
|
||||
```
|
||||
// 8 bits of difficulty, 1 in 2^8 (1 in 256) tries will succeed on average.
|
||||
const captchaDifficultyLevel = 8
|
||||
|
||||
...
|
||||
|
||||
captchaAPIURL, err = url.Parse("http://localhost:2370")
|
||||
```
|
||||
|
||||
When the Todo List app starts, it has a few procedures it runs through to ensure it's ready to run, including
|
||||
retrieving a batch of captcha challenges from the captcha API:
|
||||
|
||||
```
|
||||
func main() {
|
||||
|
||||
...
|
||||
|
||||
err = loadCaptchaChallenges()
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "can't start the app because could not loadCaptchaChallenges():"))
|
||||
}
|
||||
```
|
||||
|
||||
`loadCaptchaChallenges()` calls the `GetChallenges` API & sets the global variable `captchaChallenges`.
|
||||
|
||||
It's a good idea to do this when your app starts, to ensure that it can talk to the captcha server before it starts serving content to users.
|
||||
|
||||
The Todo List app only has one route: `/`.
|
||||
|
||||
This route displays a basic HTML page with a form, based on the template `index.html`.
|
||||
|
||||
```
|
||||
http.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
|
||||
...
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
This route does 4 things:
|
||||
|
||||
1. If it was a `POST` request, call the `Verify` endpoint to ensure that a valid captcha challenge and nonce were posted.
|
||||
2. If it was a *valid* `POST` request, add the posted `item` string to the global list variable `items`.
|
||||
3. Check if the global `captchaChallenges` list is running out, if it is, kick off a background process to grab more from the `GetChallenges` API.
|
||||
4. Consume one challenge string from the global `captchaChallenges` list variable and output an HTML page containing that challenge.
|
||||
|
||||
The captcha API (`GetChallenges` and `Verify`) was designed this way to optimize the performance of your application; instead of calling something like *GetCaptchaChallenge* for every single request, your application can load batches of captcha challenges asychronously in the background, and always have a challenge loaded into local memory & ready to go.
|
||||
|
||||
However, you have to make sure that you are using it right:
|
||||
|
||||
- You must ensure that you only serve each challenge once, and
|
||||
- You must only call `GetChallenges` when necessary (when you are running out of challenges). If you call it too often you may accidentally expire otherwise-valid challenges before they can be verified.
|
||||
|
||||
---
|
||||
|
||||
Anyways, lets get on with things & look at how the Todo List app renders its HTML page.
|
||||
There are two main important parts, the form and the javascript at the bottom:
|
||||
|
||||
```
|
||||
<form method="POST" action="/">
|
||||
<input type="text" name="item" />
|
||||
<input type="hidden" name="challenge" value="{{ .Challenge }}" />
|
||||
<input type="hidden" name="nonce" />
|
||||
<input type="submit" disabled="true" value="Add" />
|
||||
<div class="captcha-container"
|
||||
data-sqr-captcha-url="{{ .CaptchaURL }}"
|
||||
data-sqr-captcha-challenge="{{ .Challenge }}"
|
||||
data-sqr-captcha-callback="myCaptchaCallback">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
...
|
||||
|
||||
<script>
|
||||
window.myCaptchaCallback = (nonce) => {
|
||||
document.querySelector("form input[name='nonce']").value = nonce;
|
||||
document.querySelector("form input[type='submit']").disabled = false;
|
||||
};
|
||||
</script>
|
||||
<script src="{{ .CaptchaURL }}/static/captcha.js"></script>
|
||||
```
|
||||
|
||||
⚠️ **NOTE** that the element with the `sqr-captcha` data properties is placed **inside a form element**. This is required, to allow the captcha to know which input elements it needs to trigger on. We only want the captcha to trigger when the user actually intends to submit the form; otherwise we are wasting a lot of their CPU cycles for no reason!
|
||||
|
||||
> The double curly brace elements like `{{ .Challenge }}` are Golang string template interpolations.
|
||||
They will place values, usually strings, that are passed into the template from the application.
|
||||
|
||||
When the page loads, the `captcha.js` script will execute, querying the page for all elements with the `data-sqr-captcha-challenge`
|
||||
property. It will then validate each element to make sure it also has the `data-sqr-captcha-url` and `data-sqr-captcha-callback` properties. For each element it found, it will locate the `<form>` element enclosing the element. If none are found, it will throw an error. Otherwise, it will set up an event listener on every form element inside that form, so that as soon as the user starts filling out the form, the captcha display will pop up and the Proof of Work will begin.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
VERSION="0.0.6"
|
||||
VERSION="0.0.7"
|
||||
|
||||
rm -rf dockerbuild || true
|
||||
mkdir dockerbuild
|
||||
|
53
example/index.html
Normal file
53
example/index.html
Normal file
@ -0,0 +1,53 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>📋 Todo List</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
.captcha-container {
|
||||
margin-top: 1em;
|
||||
font-size: 10px;
|
||||
}
|
||||
li::marker {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
li {
|
||||
font-size: 1em;
|
||||
}
|
||||
input[type='text'] {
|
||||
width: 25em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📋 Todo List</h1>
|
||||
<ol>
|
||||
{{ range $index, $item := .Items }}
|
||||
<li>{{ $item }}</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<form method="POST" action="/">
|
||||
<input type="text" name="item" />
|
||||
<input type="hidden" name="challenge" value="{{ .Challenge }}" />
|
||||
<input type="hidden" name="nonce" />
|
||||
<input type="submit" disabled="true" value="Add" />
|
||||
<div class="captcha-container"
|
||||
data-sqr-captcha-url="{{ .CaptchaURL }}"
|
||||
data-sqr-captcha-challenge="{{ .Challenge }}"
|
||||
data-sqr-captcha-callback="myCaptchaCallback">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</li>
|
||||
</ol>
|
||||
<script>
|
||||
window.myCaptchaCallback = (nonce) => {
|
||||
document.querySelector("form input[name='nonce']").value = nonce;
|
||||
document.querySelector("form input[type='submit']").disabled = false;
|
||||
};
|
||||
</script>
|
||||
<script src="{{ .CaptchaURL }}/static/captcha.js"></script>
|
||||
</body>
|
||||
</html>
|
218
example/main.go
Normal file
218
example/main.go
Normal file
@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var httpClient *http.Client
|
||||
var captchaAPIURL *url.URL
|
||||
var captchaChallenges []string
|
||||
|
||||
var items []string
|
||||
|
||||
// 8 bits of difficulty, 1 in 2^8 (1 in 256) tries will succeed on average.
|
||||
const captchaDifficultyLevel = 8
|
||||
|
||||
func main() {
|
||||
|
||||
httpClient = &http.Client{
|
||||
Timeout: time.Second * time.Duration(5),
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "can't start the app because could not loadCaptchaChallenges():"))
|
||||
}
|
||||
|
||||
_, err = ioutil.ReadFile("index.html")
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "can't start the app because can't open the template file. Are you in the right directory? "))
|
||||
}
|
||||
|
||||
http.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
|
||||
// The user submitted a POST request, attempting to add a new item to the list
|
||||
if request.Method == "POST" {
|
||||
|
||||
// Ask the captcha server if the user's proof of work result is legit,
|
||||
// and if not, return HTTP 400 Bad Request
|
||||
err := request.ParseForm()
|
||||
if err == nil {
|
||||
err = validateCaptcha(request.Form.Get("challenge"), request.Form.Get("nonce"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
responseWriter.WriteHeader(400)
|
||||
responseWriter.Write([]byte(fmt.Sprintf("400 bad request: %s", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Validation passed, add the user's new item to the list
|
||||
items = append(items, request.Form.Get("item"))
|
||||
}
|
||||
|
||||
// if it looks like we will run out of challenges soon, then kick off a goroutine to go get more in the background
|
||||
// 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()
|
||||
}
|
||||
|
||||
// if we somehow completely ran out of challenges, load more synchronously
|
||||
if captchaChallenges == nil || len(captchaChallenges) == 0 {
|
||||
err = loadCaptchaChallenges()
|
||||
if err != nil {
|
||||
log.Printf("loading captcha challenges failed: %v", err)
|
||||
responseWriter.WriteHeader(500)
|
||||
responseWriter.Write([]byte("captcha api error"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// This gets & consumes the next challenge from the begining of the slice
|
||||
challenge := captchaChallenges[0]
|
||||
captchaChallenges = captchaChallenges[1:]
|
||||
|
||||
// render the page HTML & output the result to the web browser
|
||||
htmlBytes, err := renderPageTemplate(challenge)
|
||||
if err != nil {
|
||||
log.Printf("renderPageTemplate(): %v", err)
|
||||
responseWriter.WriteHeader(500)
|
||||
responseWriter.Write([]byte("500 internal server error"))
|
||||
return
|
||||
}
|
||||
responseWriter.Write(htmlBytes)
|
||||
})
|
||||
|
||||
log.Println("📋 Todo List example application listening on port 8080")
|
||||
|
||||
err = http.ListenAndServe(":8080", nil)
|
||||
|
||||
// if got this far it means server crashed!
|
||||
panic(err)
|
||||
}
|
||||
|
||||
func renderPageTemplate(challenge string) ([]byte, error) {
|
||||
|
||||
// in a real application in production you would read the template file & parse it 1 time when the app starts
|
||||
// I'm doing it for each request here just to make it easier to hack on it while its running 😇
|
||||
indexHTMLTemplateString, err := ioutil.ReadFile("index.html")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "can't open the template file. Are you in the right directory? ")
|
||||
}
|
||||
pageTemplate, err := template.New("master").Parse(string(indexHTMLTemplateString))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "can't parse the template file: ")
|
||||
}
|
||||
|
||||
// constructing an instance of an anonymous struct type to contain all the data
|
||||
// that we need to pass to the template
|
||||
pageData := struct {
|
||||
Challenge string
|
||||
Items []string
|
||||
CaptchaURL string
|
||||
}{
|
||||
Challenge: challenge,
|
||||
Items: items,
|
||||
CaptchaURL: captchaAPIURL.String(),
|
||||
}
|
||||
var outputBuffer bytes.Buffer
|
||||
err = pageTemplate.Execute(&outputBuffer, pageData)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "rendering page template failed: ")
|
||||
}
|
||||
|
||||
return outputBuffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func loadCaptchaChallenges() error {
|
||||
|
||||
query := url.Values{}
|
||||
query.Add("difficultyLevel", strconv.Itoa(captchaDifficultyLevel))
|
||||
|
||||
loadURL := url.URL{
|
||||
Scheme: captchaAPIURL.Scheme,
|
||||
Host: captchaAPIURL.Host,
|
||||
Path: filepath.Join(captchaAPIURL.Path, "GetChallenges"),
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
captchaRequest, err := http.NewRequest("POST", loadURL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := httpClient.Do(captchaRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseBytes, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return fmt.Errorf(
|
||||
"load proof of work captcha challenges api returned http %d: %s",
|
||||
response.StatusCode, string(responseBytes),
|
||||
)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBytes, &captchaChallenges)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(captchaChallenges) == 0 {
|
||||
return errors.New("proof of work captcha challenges api returned empty array")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCaptcha(challenge, nonce string) error {
|
||||
query := url.Values{}
|
||||
query.Add("challenge", challenge)
|
||||
query.Add("nonce", nonce)
|
||||
|
||||
verifyURL := url.URL{
|
||||
Scheme: captchaAPIURL.Scheme,
|
||||
Host: captchaAPIURL.Host,
|
||||
Path: filepath.Join(captchaAPIURL.Path, "Verify"),
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
captchaRequest, err := http.NewRequest("POST", verifyURL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := httpClient.Do(captchaRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return errors.New("proof of work captcha validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
16
main.go
16
main.go
@ -14,7 +14,7 @@ import (
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
const numberOfChallenges = 100
|
||||
const numberOfChallenges = 1000
|
||||
const deprecateAfterGenerations = 10
|
||||
const portNumber = 2370
|
||||
|
||||
@ -143,7 +143,7 @@ func main() {
|
||||
|
||||
requestQuery := request.URL.Query()
|
||||
challengeBase64 := requestQuery.Get("challenge")
|
||||
nonceBase64 := requestQuery.Get("nonce")
|
||||
nonceHex := requestQuery.Get("nonce")
|
||||
|
||||
if _, has := challenges[challengeBase64]; !has {
|
||||
http.Error(
|
||||
@ -160,13 +160,13 @@ func main() {
|
||||
delete(challenges, challengeBase64)
|
||||
|
||||
nonceBuffer := make([]byte, 8)
|
||||
bytesWritten, err := base64.StdEncoding.Decode(nonceBuffer, []byte(nonceBase64))
|
||||
if nonceBase64 == "" || err != nil {
|
||||
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 base64 decoded",
|
||||
nonceBase64,
|
||||
"400 bad request: nonce given by url param ?nonce=%s could not be hex decoded",
|
||||
nonceHex,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
@ -218,7 +218,7 @@ func main() {
|
||||
responseWriter,
|
||||
fmt.Sprintf(
|
||||
"400 bad request: nonce given by url param ?nonce=%s did not result in a hash that meets the required difficulty",
|
||||
nonceBase64,
|
||||
nonceHex,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
@ -231,6 +231,8 @@ func main() {
|
||||
|
||||
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!
|
||||
|
143
proofOfWorkerStub.js
Normal file
143
proofOfWorkerStub.js
Normal file
@ -0,0 +1,143 @@
|
||||
|
||||
// IN ORDER FOR CHANGES TO THIS FILE TO "TAKE" AND BE USED IN THE APP, THE BUILD IN wasm_build HAS TO BE RE-RUN
|
||||
|
||||
// scrypt and scryptPromise will be filled out by js code that gets appended below this script by the wasm_build process
|
||||
let scrypt;
|
||||
let scryptPromise;
|
||||
|
||||
let working = false;
|
||||
const batchSize = 8;
|
||||
|
||||
onmessage = function(e) {
|
||||
if(e.data.stop) {
|
||||
working = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeBase64 = e.data.challenge;
|
||||
const workerId = e.data.workerId;
|
||||
if(!challengeBase64) {
|
||||
postMessage({
|
||||
type: "error",
|
||||
challenge: challengeBase64,
|
||||
message: `challenge was not provided`
|
||||
});
|
||||
}
|
||||
working = true;
|
||||
let challengeJSON = null;
|
||||
let challenge = null;
|
||||
try {
|
||||
challengeJSON = atob(challengeBase64);
|
||||
} catch (err) {
|
||||
postMessage({
|
||||
type: "error",
|
||||
challenge: challengeBase64,
|
||||
message: `couldn't decode challenge '${challengeBase64}' as base64: ${err}`
|
||||
});
|
||||
}
|
||||
try {
|
||||
challenge = JSON.parse(challengeJSON);
|
||||
} catch (err) {
|
||||
postMessage({
|
||||
type: "error",
|
||||
challenge: challengeBase64,
|
||||
message: `couldn't parse challenge '${challengeJSON}' as json: ${err}`
|
||||
});
|
||||
}
|
||||
|
||||
challenge = {
|
||||
cpuAndMemoryCost: challenge.N,
|
||||
blockSize: challenge.r,
|
||||
paralellization: challenge.p,
|
||||
keyLength: challenge.klen,
|
||||
preimage: challenge.i,
|
||||
difficulty: challenge.d,
|
||||
difficultyLevel: challenge.dl
|
||||
}
|
||||
|
||||
const probabilityOfFailurePerAttempt = 1-(1/Math.pow(2, challenge.difficultyLevel));
|
||||
|
||||
let i = workerId * Math.pow(2, challenge.difficultyLevel) * 1000;
|
||||
const hexPreimage = base64ToHex(challenge.preimage);
|
||||
let smallestHash = challenge.difficulty.split("").map(x => "f").join("");
|
||||
|
||||
postMessage({
|
||||
type: "progress",
|
||||
challenge: challengeBase64,
|
||||
attempts: 0,
|
||||
smallestHash: smallestHash,
|
||||
difficulty: challenge.difficulty,
|
||||
probabilityOfFailurePerAttempt: probabilityOfFailurePerAttempt
|
||||
});
|
||||
|
||||
const doWork = () => {
|
||||
|
||||
var j = 0;
|
||||
while(j < batchSize) {
|
||||
j++;
|
||||
i++;
|
||||
|
||||
let nonceHex = i.toString(16);
|
||||
if((nonceHex.length % 2) == 1) {
|
||||
nonceHex = `0${nonceHex}`;
|
||||
}
|
||||
const hashHex = scrypt(
|
||||
nonceHex,
|
||||
hexPreimage,
|
||||
challenge.cpuAndMemoryCost,
|
||||
challenge.blockSize,
|
||||
challenge.paralellization,
|
||||
challenge.keyLength
|
||||
);
|
||||
|
||||
//console.log(i.toString(16), hashHex);
|
||||
|
||||
const endOfHash = hashHex.substring(hashHex.length-challenge.difficulty.length);
|
||||
if(endOfHash < smallestHash) {
|
||||
smallestHash = endOfHash
|
||||
}
|
||||
if(endOfHash <= challenge.difficulty) {
|
||||
postMessage({
|
||||
type: "success",
|
||||
challenge: challengeBase64,
|
||||
nonce: nonceHex,
|
||||
smallestHash: endOfHash,
|
||||
difficulty: challenge.difficulty
|
||||
});
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
postMessage({
|
||||
type: "progress",
|
||||
challenge: challengeBase64,
|
||||
attempts: batchSize,
|
||||
smallestHash: smallestHash,
|
||||
difficulty: challenge.difficulty,
|
||||
probabilityOfFailurePerAttempt: probabilityOfFailurePerAttempt
|
||||
});
|
||||
|
||||
if(working) {
|
||||
this.setTimeout(doWork, 1);
|
||||
}
|
||||
};
|
||||
|
||||
if(scrypt) {
|
||||
doWork();
|
||||
} else {
|
||||
scryptPromise.then(() => {
|
||||
doWork();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/39460182/decode-base64-to-hexadecimal-string-with-javascript
|
||||
function base64ToHex(str) {
|
||||
const raw = atob(str);
|
||||
let result = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const hex = raw.charCodeAt(i).toString(16);
|
||||
result += (hex.length === 2 ? hex : '0' + hex);
|
||||
}
|
||||
return result;
|
||||
}
|
BIN
readme/screencast.gif
Normal file
BIN
readme/screencast.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 399 KiB |
@ -1,23 +1,42 @@
|
||||
.sqr-captcha {
|
||||
background-color: #ddd;
|
||||
border: 1px solid #9359fa;
|
||||
border-radius: 1rem;
|
||||
font-size: 1.2rem;
|
||||
padding: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
border-radius: 1em;
|
||||
font-size: 1.2em;
|
||||
padding: 1em;
|
||||
padding-top: 0.5em;
|
||||
border-bottom: 2px solid #452775;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 2em;
|
||||
min-width: 37em;
|
||||
}
|
||||
|
||||
.sqr-captcha-link {
|
||||
color: #333333;
|
||||
font-weight: black;
|
||||
color: #452775;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
font-size: 1.4em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 410px) {
|
||||
.sqr-captcha {
|
||||
min-width: 25em;
|
||||
}
|
||||
.sqr-captcha-icon {
|
||||
height: 3em;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 380px) {
|
||||
.sqr-captcha-link span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sqr-captcha-link:hover,
|
||||
.sqr-captcha-link:active,
|
||||
.sqr-captcha-link:visited {
|
||||
color: #333333;
|
||||
color: #452775;
|
||||
}
|
||||
|
||||
|
||||
@ -25,13 +44,15 @@
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sqr-captcha-icon-container {
|
||||
margin-left: 1.5rem;
|
||||
margin-top: 0.2rem;
|
||||
margin-bottom: -2rem;
|
||||
margin-right: 0.2rem;
|
||||
margin-left: 1.5em;
|
||||
margin-top: 0.2em;
|
||||
margin-bottom: -2em;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
.sqr-captcha-best-hash {
|
||||
@ -39,10 +60,11 @@
|
||||
background: #585a29;
|
||||
color: #f6ff72;
|
||||
transition: background 0.5s ease-in-out, color 0.5s ease-in-out;
|
||||
padding: 0.2rem 0.8rem;
|
||||
margin-left: -0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2em 0.8em;
|
||||
padding-bottom: 0.3em;
|
||||
margin-left: -0.5em;
|
||||
border-radius: 0.5em;
|
||||
font-size: 0.8em;
|
||||
font-weight: bolder;
|
||||
display: block;
|
||||
float: right;
|
||||
@ -54,15 +76,15 @@
|
||||
}
|
||||
|
||||
.sqr-captcha-description {
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
margin-top: 1em;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.sqr-captcha-progress-bar-container {
|
||||
border-radius: 1rem;
|
||||
border-radius: 1em;
|
||||
background: #444;
|
||||
height: 1rem;
|
||||
margin-top: 1rem;
|
||||
height: 1em;
|
||||
margin-top: 1em;
|
||||
border: 1px solid #727630;
|
||||
box-sizing: content-box;
|
||||
|
||||
@ -70,14 +92,14 @@
|
||||
|
||||
.sqr-captcha-progress-bar {
|
||||
background: #f6ff72;
|
||||
height: 1rem;
|
||||
height: 1em;
|
||||
width: 0;
|
||||
border-radius: 1rem;
|
||||
border-radius: 1em;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.sqr-captcha-icon {
|
||||
height: 4rem;
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
.sqr-captcha-hidden {
|
||||
@ -87,9 +109,9 @@
|
||||
.sqr-checkmark-icon-checkmark {
|
||||
fill:none;
|
||||
stroke: #31bd82;
|
||||
stroke-width: 9rem;
|
||||
stroke-dasharray: 60rem;
|
||||
stroke-dashoffset: 74rem;
|
||||
stroke-width: 6em;
|
||||
stroke-dasharray: 60em;
|
||||
stroke-dashoffset: 74em;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
animation: 0.8s normal forwards ease-in-out sqr-draw-checkmark;
|
||||
@ -99,9 +121,9 @@
|
||||
.sqr-checkmark-icon-border {
|
||||
fill:none;
|
||||
stroke: #ccc;
|
||||
stroke-width: 4rem;
|
||||
stroke-dasharray: 110rem;
|
||||
stroke-dashoffset: 110rem;
|
||||
stroke-width: 3em;
|
||||
stroke-dasharray: 110em;
|
||||
stroke-dashoffset: 110em;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
animation: 0.8s normal forwards ease-in-out sqr-draw-checkmark-border;
|
||||
@ -122,36 +144,36 @@
|
||||
|
||||
@keyframes sqr-draw-checkmark-border {
|
||||
0% {
|
||||
stroke-dashoffset: 110rem;
|
||||
stroke-dashoffset: 110em;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 10rem;
|
||||
stroke-dashoffset: 10em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sqr-draw-checkmark {
|
||||
0% {
|
||||
stroke-dashoffset: 74rem;
|
||||
stroke-dashoffset: 74em;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 120rem;
|
||||
stroke-dashoffset: 120em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sqr-spinning-gears-small {
|
||||
0% {
|
||||
transform: translate(16.1rem, 16.1rem) rotate(0deg) translate(-16.1rem,-16.1rem);
|
||||
transform: translate(161px, 161px) rotate(0deg) translate(-161px,-161px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(16.1rem, 16.1rem) rotate(360deg) translate(-16.1rem,-16.1rem);
|
||||
transform: translate(161px, 161px) rotate(360deg) translate(-161px,-161px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sqr-spinning-gears-large {
|
||||
0% {
|
||||
transform: translate(7.3rem, 7.3rem) rotate(360deg) translate(-7.3rem,-7.3rem);
|
||||
transform: translate(73px, 73px) rotate(360deg) translate(-73px,-73px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(7.3rem, 7.3rem) rotate(0deg) translate(-7.3rem,-7.3rem);
|
||||
transform: translate(73px, 73px) rotate(0deg) translate(-73px,-73px);
|
||||
}
|
||||
}
|
@ -2,6 +2,10 @@
|
||||
|
||||
const numberOfWebWorkersToCreate = 4;
|
||||
|
||||
window.sqrCaptchaReset = () => {
|
||||
window.sqrCaptchaInitDone = false;
|
||||
};
|
||||
|
||||
window.sqrCaptchaInit = () => {
|
||||
if(window.sqrCaptchaInitDone) {
|
||||
console.error("sqrCaptchaInit was called twice!");
|
||||
@ -25,6 +29,23 @@
|
||||
url = url.substring(0, url.length-1)
|
||||
}
|
||||
}
|
||||
|
||||
if(!element.dataset.sqrCaptchaCallback) {
|
||||
console.error("error: element with data-sqr-captcha-challenge property is missing the data-sqr-captcha-callback property");
|
||||
return
|
||||
}
|
||||
|
||||
if(typeof element.dataset.sqrCaptchaCallback != "string") {
|
||||
console.error("error: data-sqr-captcha-callback property should be of type 'string'");
|
||||
return
|
||||
}
|
||||
|
||||
const callback = getCallbackFromGlobalNamespace(element.dataset.sqrCaptchaCallback);
|
||||
if(!callback) {
|
||||
console.warn(`warning: data-sqr-captcha-callback '${element.dataset.sqrCaptchaCallback}' `
|
||||
+ "is not defined in the global namespace yet. It had better be defined by the time it's called!");
|
||||
}
|
||||
|
||||
|
||||
let form = null;
|
||||
let parent = element.parentElement;
|
||||
@ -41,9 +62,20 @@
|
||||
//todo
|
||||
}
|
||||
|
||||
renderCaptcha(element, url);
|
||||
if(!document.querySelector(`link[href='${url}/static/captcha.css']`)) {
|
||||
|
||||
const stylesheet = createElement(document.head, "link", {
|
||||
"rel": "stylesheet",
|
||||
"charset": "utf8",
|
||||
});
|
||||
stylesheet.onload = () => renderCaptcha(element);
|
||||
stylesheet.setAttribute("href", `${url}/static/captcha.css`);
|
||||
} else {
|
||||
renderCaptcha(element);
|
||||
}
|
||||
|
||||
const onFormWasTouched = () => {
|
||||
|
||||
const challenge = element.dataset.sqrCaptchaChallenge;
|
||||
if(!challengesMap[challenge]) {
|
||||
challengesMap[challenge] = {
|
||||
@ -129,6 +161,7 @@
|
||||
const checkmark = element.querySelector(".sqr-checkmark-icon");
|
||||
const gears = element.querySelector(".sqr-gears-icon");
|
||||
const bestHashElement = element.querySelector(".sqr-captcha-best-hash");
|
||||
const description = element.querySelector(".sqr-captcha-description");
|
||||
challengeState.smallestHash = e.data.smallestHash;
|
||||
bestHashElement.textContent = getHashProgressText(challengeState);
|
||||
bestHashElement.classList.add("sqr-captcha-best-hash-done");
|
||||
@ -137,10 +170,27 @@
|
||||
gears.style.display = "none";
|
||||
progressBar.style.width = "100%";
|
||||
|
||||
// console.log("success: " + e.data.nonce)
|
||||
// console.log("hash: " + e.data.smallestHash)
|
||||
// console.log("difficulty: " + e.data.difficulty)
|
||||
description.innerHTML = "";
|
||||
createElement(
|
||||
description,
|
||||
"a",
|
||||
{"href": "https://en.wikipedia.org/wiki/Proof_of_work"},
|
||||
"Proof of Work"
|
||||
);
|
||||
appendFragment(description, " complete, you may now submit your post. ");
|
||||
createElement(description, "br");
|
||||
appendFragment(description, "This an accessible & privacy-respecting anti-spam measure. ");
|
||||
|
||||
webWorkers.forEach(x => x.postMessage({stop: "STOP"}));
|
||||
|
||||
const callback = getCallbackFromGlobalNamespace(element.dataset.sqrCaptchaCallback);
|
||||
if(!callback) {
|
||||
console.error(`error: data-sqr-captcha-callback '${element.dataset.sqrCaptchaCallback}' `
|
||||
+ "is not defined in the global namespace!");
|
||||
} else {
|
||||
callback(e.data.nonce);
|
||||
}
|
||||
|
||||
} else if(e.data.type == "error") {
|
||||
console.error(`error: webworker errored out: '${e.data.message}'`);
|
||||
} else {
|
||||
@ -157,6 +207,11 @@
|
||||
x.postMessage({ ...arg, workerId: i })
|
||||
})
|
||||
};
|
||||
|
||||
window.sqrCaptchaReset = () => {
|
||||
window.sqrCaptchaInitDone = false;
|
||||
webWorkers.forEach(x => x.terminate());
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -165,6 +220,20 @@
|
||||
window.sqrCaptchaInit();
|
||||
}
|
||||
|
||||
function getCallbackFromGlobalNamespace(callbackString) {
|
||||
const callbackPath = callbackString.split(".");
|
||||
let context = window;
|
||||
callbackPath.forEach(pathElement => {
|
||||
if(!context[pathElement]) {
|
||||
return null;
|
||||
} else {
|
||||
context = context[pathElement];
|
||||
}
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function getHashProgressText(challengeState) {
|
||||
const durationSeconds = ((new Date().getTime()) - challengeState.startTime)/1000;
|
||||
let hashesPerSecond = '[...]';
|
||||
@ -181,36 +250,30 @@
|
||||
return str.length < max ? leftPad(" " + str, max) : str;
|
||||
}
|
||||
|
||||
function renderCaptcha(parent, baseUrl) {
|
||||
function renderCaptcha(parent) {
|
||||
const svgXMLNS = "http://www.w3.org/2000/svg";
|
||||
const xmlnsXMLNS = 'http://www.w3.org/2000/xmlns/';
|
||||
const xmlSpaceXMLNS = 'http://www.w3.org/XML/1998/namespace';
|
||||
|
||||
|
||||
if(!document.querySelector(`link[href='${baseUrl}/static/captcha.css']`)) {
|
||||
createElement(document.head, "link", {
|
||||
"rel": "stylesheet",
|
||||
"charset": "utf8",
|
||||
"href": `${baseUrl}/static/captcha.css`,
|
||||
});
|
||||
}
|
||||
parent.innerHTML = "";
|
||||
|
||||
const main = createElement(parent, "div", {"class": "sqr-captcha sqr-captcha-hidden"});
|
||||
const mainRow = createElement(main, "div", {"class": "sqr-captcha-row"});
|
||||
const mainColumn = createElement(mainRow, "div");
|
||||
const headerRow = createElement(mainColumn, "div");
|
||||
createElement(
|
||||
const headerLink = createElement(
|
||||
headerRow,
|
||||
"a",
|
||||
{
|
||||
"class": "sqr-captcha-link",
|
||||
"href": "https://git.sequentialread.com/forest/sequentialread-pow-captcha"
|
||||
},
|
||||
"PoW! Captcha"
|
||||
"💥PoW! "
|
||||
);
|
||||
createElement(headerLink, "span", null, "Captcha");
|
||||
createElement(headerRow, "div", {"class": "sqr-captcha-best-hash"}, "loading...");
|
||||
const description = createElement(mainColumn, "div", {"class": "sqr-captcha-description"});
|
||||
appendFragment(description, "Please wait for your computer to calculate a ");
|
||||
appendFragment(description, "Please wait for your browser to calculate a ");
|
||||
createElement(
|
||||
description,
|
||||
"a",
|
||||
@ -219,7 +282,7 @@
|
||||
);
|
||||
appendFragment(description, ". ");
|
||||
createElement(description, "br");
|
||||
appendFragment(description, "This a privacy-respecting anti-spam measure. ");
|
||||
appendFragment(description, "This an accessible & privacy-respecting anti-spam measure. ");
|
||||
const progressBarContainer = createElement(main, "div", {
|
||||
"class": "sqr-captcha-progress-bar-container sqr-captcha-hidden"
|
||||
});
|
||||
@ -247,7 +310,7 @@
|
||||
"xmlns": [xmlnsXMLNS, svgXMLNS],
|
||||
"xml:space": [xmlSpaceXMLNS, 'preserve'],
|
||||
"version": "1.1",
|
||||
"viewBox": "-30 0 250 218",
|
||||
"viewBox": "-30 -5 250 223",
|
||||
"class": "sqr-gears-icon sqr-captcha-icon sqr-captcha-hidden"
|
||||
});
|
||||
createElementNS(gearsIcon, svgXMLNS, "path", {
|
||||
|
@ -62,7 +62,7 @@ onmessage = function(e) {
|
||||
|
||||
const probabilityOfFailurePerAttempt = 1-(1/Math.pow(2, challenge.difficultyLevel));
|
||||
|
||||
let i = workerId * Math.pow(2, challenge.difficulty.length) * 100;
|
||||
let i = workerId * Math.pow(2, challenge.difficultyLevel) * 1000;
|
||||
const hexPreimage = base64ToHex(challenge.preimage);
|
||||
let smallestHash = challenge.difficulty.split("").map(x => "f").join("");
|
||||
|
||||
@ -105,7 +105,7 @@ onmessage = function(e) {
|
||||
postMessage({
|
||||
type: "success",
|
||||
challenge: challengeBase64,
|
||||
nonce: i.toString(16),
|
||||
nonce: nonceHex,
|
||||
smallestHash: endOfHash,
|
||||
difficulty: challenge.difficulty
|
||||
});
|
||||
|
@ -33,7 +33,7 @@ console.log(`
|
||||
`)
|
||||
|
||||
// add the actual webworker logic at the top, while filtering out comments
|
||||
const stubJS = fs.readFileSync("../static/proofOfWorkerStub.js", { encoding: "utf8" });
|
||||
const stubJS = fs.readFileSync("../proofOfWorkerStub.js", { encoding: "utf8" });
|
||||
console.log(stubJS.split("\n").filter(x => !x.startsWith("//")).join("\n"));
|
||||
|
||||
console.log(`
|
||||
|
Loading…
x
Reference in New Issue
Block a user