diff options
author | Runxi Yu <me@runxiyu.org> | 2025-03-23 02:11:41 +0800 |
---|---|---|
committer | Runxi Yu <me@runxiyu.org> | 2025-03-23 03:54:33 +0800 |
commit | e68745e4cb31e6d6cde7b7c1c84b39d862a7d19d (patch) | |
tree | 69203b33397d67388bb278ab8c7c178e89f8399b /main.go | |
parent | Basic reverse proxy (diff) | |
download | powxy-e68745e4cb31e6d6cde7b7c1c84b39d862a7d19d.tar.gz powxy-e68745e4cb31e6d6cde7b7c1c84b39d862a7d19d.tar.zst powxy-e68745e4cb31e6d6cde7b7c1c84b39d862a7d19d.zip |
Basic proof of work
Diffstat (limited to 'main.go')
-rw-r--r-- | main.go | 224 |
1 files changed, 214 insertions, 10 deletions
@@ -1,33 +1,237 @@ package main import ( + "crypto/rand" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "errors" + "flag" + "html/template" "io" "log" "maps" "net/http" + "strings" + "time" + "unsafe" ) +var ( + difficulty uint + listenAddr string + destHost string +) + +func init() { + flag.UintVar(&difficulty, "difficulty", 20, "leading zero bits required for the challenge") + flag.StringVar(&listenAddr, "listen", ":8081", "address to listen on") + flag.StringVar(&destHost, "host", "127.0.0.1:8080", "destination host to proxy to") + flag.Parse() +} + var client = http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } +var ( + privkey = make([]byte, 32) + privkeyHash = make([]byte, 0, sha256.Size) +) + +func init() { + if _, err := rand.Read(privkey); err != nil { + log.Fatal(err) + } + h := sha256.New() + h.Write(privkey) + privkeyHash = h.Sum(nil) +} + +var tmpl *template.Template + +func init() { + var err error + tmpl, err = template.New("powxy").Parse(` +<!DOCTYPE html> +<html> +<head> +<title>Proof of Work Challenge</title> +</head> +<body> +<h1>Proof of Work Challenge</h1> +<p>You must complete this proof of work challenge before you could access this site.</p> +{{- if .Message }} +<p><strong>{{ .Message }}</strong></p> +{{- end }} +<p>Select a value, such that when it is appended to the decoded form of the following base64 string, and a SHA-256 hash is taken as a whole, the first {{ .NeedBits }} bits of the SHA-256 hash are zeros. Within one octet, higher bits are considered to be in front of lower bits.</p> +<p>{{ .UnsignedTokenBase64 }}</p> +<form method="POST"> +<p> +Encode your selected value in base64 and submit it below: +</p> +<input name="powxy" type="text" /> +<input type="submit" value="Submit" /> +</form> +</body> +</html> +`) + if err != nil { + log.Fatal(err) + } +} + +type tparams struct { + UnsignedTokenBase64 string + NeedBits uint + Message string +} + func main() { - log.Fatal(http.ListenAndServe(":8081", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + log.Fatal(http.ListenAndServe(listenAddr, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { log.Println(request.RemoteAddr, request.RequestURI) - request.Host = "127.0.0.1:8080" - request.URL.Host = "127.0.0.1:8080" - request.URL.Scheme = "http" - request.RequestURI = "" + cookie, err := request.Cookie("powxy") + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + http.Error(writer, "error fetching cookie", http.StatusInternalServerError) + } + } + + expectedToken := makeSignedToken(request) + + if validateCookie(cookie, expectedToken) { + proxyRequest(writer, request) + return + } + + authPage := func(message string) { + tmpl.Execute(writer, tparams{ + UnsignedTokenBase64: base64.StdEncoding.EncodeToString(expectedToken[:sha256.Size]), + Message: message, + NeedBits: difficulty, + }) + } + + if request.ParseForm() != nil { + authPage("You submitted a malformed form.") + return + } + + formValues, ok := request.PostForm["powxy"] + if !ok { + authPage("") + return + } else if len(formValues) != 1 { + authPage("You submitted an invalid number of form values.") + return + } - response, err := client.Do(request) + nonce, err := base64.StdEncoding.DecodeString(formValues[0]) if err != nil { - http.Error(writer, err.Error(), http.StatusBadGateway) + authPage("Your submission was improperly encoded.") + return + } + + h := sha256.New() + h.Write(expectedToken[:sha256.Size]) + h.Write(nonce) + ck := h.Sum(nil) + if !validateBitZeros(ck, difficulty) { + authPage("Your submission was incorrect.") return } + + http.SetCookie(writer, &http.Cookie{ + Name: "powxy", + Value: base64.StdEncoding.EncodeToString(expectedToken), + }) - maps.Copy(writer.Header(), response.Header) - writer.WriteHeader(response.StatusCode) - io.Copy(writer, response.Body) + http.Redirect(writer, request, "", http.StatusSeeOther) }))) } + +func validateCookie(cookie *http.Cookie, expectedToken []byte) bool { + if cookie == nil { + return false + } + + gotToken, err := base64.StdEncoding.DecodeString(cookie.Value) + if err != nil { + return false + } + + return subtle.ConstantTimeCompare(gotToken, expectedToken) == 1 +} +func makeSignedToken(request *http.Request) []byte { + buf := make([]byte, 0, 2 * sha256.Size) + + timeBuf := make([]byte, binary.MaxVarintLen64) + binary.PutVarint(timeBuf, time.Now().Unix() / 604800) + + remoteAddr, _, _ := strings.Cut(request.RemoteAddr, ":") + + h := sha256.New() + h.Write(timeBuf) + h.Write(stringToBytes(remoteAddr)) + h.Write(stringToBytes(request.Header.Get("User-Agent"))) + h.Write(stringToBytes(request.Header.Get("Accept-Encoding"))) + h.Write(stringToBytes(request.Header.Get("Accept-Language"))) + h.Write(privkeyHash) + buf = h.Sum(buf) + if len(buf) != sha256.Size { + panic("unexpected buffer length after hashing contents") + } + + mac := hmac.New(sha256.New, privkey) + mac.Write(buf) + buf = mac.Sum(buf) + if len(buf) != 2 * sha256.Size { + panic("unexpected buffer length after hmac") + } + + return buf +} + +func proxyRequest(writer http.ResponseWriter, request *http.Request) { + request.Host = destHost + request.URL.Host = destHost + request.URL.Scheme = "http" + request.RequestURI = "" + + response, err := client.Do(request) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadGateway) + return + } + + maps.Copy(writer.Header(), response.Header) + writer.WriteHeader(response.StatusCode) + io.Copy(writer, response.Body) +} + +func stringToBytes(s string) (bytes []byte) { + return unsafe.Slice(unsafe.StringData(s), len(s)) +} + +func validateBitZeros(bs []byte, n uint) bool { + q := n / 8 + r := n % 8 + + for i := uint(0); i < q; i++ { + if bs[i] != 0 { + return false + } + } + + if r > 0 { + mask := byte(0xFF << (8 - r)) + if bs[q]&mask != 0 { + return false + } + } + + return true +} |