From 244667457ae2065ceae51fc86b20fdb7a423f064 Mon Sep 17 00:00:00 2001
From: y a t s <140337963+y-a-t-s@users.noreply.github.com>
Date: Sun, 25 Jan 2026 14:41:59 -0500
Subject: [PATCH] Initial commit
---
.gitignore | 13 +++++
README.md | 14 +++++
UNLICENSE | 24 ++++++++
cerberus.go | 141 +++++++++++++++++++++++++++++++++++++++++++++++
cerberus_test.go | 100 +++++++++++++++++++++++++++++++++
go.mod | 13 +++++
go.sum | 9 +++
http.go | 129 +++++++++++++++++++++++++++++++++++++++++++
8 files changed, 443 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 UNLICENSE
create mode 100644 cerberus.go
create mode 100644 cerberus_test.go
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 http.go
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5932476
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+.*
+!.gitignore
+
+*.json
+*.log
+
+build/
+data-dir-*/
+tmp/
+
+GPATH
+GRTAGS
+GTAGS
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e252871
--- /dev/null
+++ b/README.md
@@ -0,0 +1,14 @@
+# Cerberus
+
+A fast Tartarus solver lib.
+
+For technical details, you can try asking Josh (not me) or reverse engineer the *wonderful* solver solutions offered on Tartarus' no-js page.
+Good luck.
+
+
+
+Donations are always appreciated but never expected nor required:
+
+XMR: `8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn`
+
+[Other crypto (anonpay)](https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=) ([Tor version](http://trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=))
diff --git a/UNLICENSE b/UNLICENSE
new file mode 100644
index 0000000..68a49da
--- /dev/null
+++ b/UNLICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to
diff --git a/cerberus.go b/cerberus.go
new file mode 100644
index 0000000..988fa04
--- /dev/null
+++ b/cerberus.go
@@ -0,0 +1,141 @@
+package cerberus
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "net/url"
+ "runtime"
+
+ "github.com/minio/sha256-simd"
+)
+
+type Challenge struct {
+ Salt string // Challenge salt from server.
+ Diff uint32 // Difficulty level.
+ Steps uint32 // Time limit for answer in minutes.
+ host *url.URL
+}
+
+type Solution struct {
+ Salt string
+ Nonce uint32
+ Redirect string
+
+ Hash []byte
+ host *url.URL
+}
+
+// Brute force nonces until a valid solution is found.
+func genHashes(ctx context.Context, c Challenge) <-chan Solution {
+ var (
+ out = make(chan Solution, 1)
+ sha = sha256.New()
+ nonce = rand.Uint32()
+ redirect = "/" // TODO: figure out non-homepage redirects.
+ )
+
+ go func() {
+ defer close(out)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ sha.Write(fmt.Append(nil, c.Salt, nonce))
+
+ sol := Solution{
+ Salt: c.Salt,
+ Nonce: nonce,
+ Redirect: redirect,
+ Hash: sha.Sum(nil),
+ }
+ // Ensure we don't hang if out channel is full on ctx close.
+ select {
+ case <-ctx.Done():
+ return
+ case out <- sol:
+ }
+
+ // Reset hasher input for next iteration.
+ sha.Reset()
+ nonce++
+ }
+ }()
+
+ return out
+}
+
+// Given difficulty is measured in number of leading 0 bits.
+func checkZeros(diff uint32, hash []byte) bool {
+ var (
+ rem = diff % 8 // Remainder after dividing diff (given in bits) to bytes.
+ nbytes = (diff - rem) / 8 // Amount of 0x0 bytes we can divide difficulty bits into.
+
+ mask uint8 // Mask to check remaining bits.
+ )
+
+ lh := uint32(len(hash))
+ // Check bounds for the loops found below.
+ if lh < nbytes || (rem > 0 && lh < nbytes+1) {
+ return false
+ }
+
+ // First, we count the number of leading 0x0 bytes.
+ for i := range nbytes {
+ if b := hash[i]; b != 0x0 {
+ return false
+ }
+ }
+ // If we don't have any more bits to check, return.
+ if rem == 0 {
+ return true
+ }
+
+ // Create bitmask by setting a bit to 1 for each remaining bit to check.
+ // The mask is built from right-to-left and then shifted to the LHS of the octet.
+ for range rem {
+ mask <<= 1
+ mask += 1
+ }
+ // Shift 1s we just added to the LHS of the octet.
+ mask <<= 8 - rem
+
+ return hash[nbytes]&mask == 0x0
+}
+
+// Solve Challenge c. Returns Solution that can be submitted.
+func Solve(ctx context.Context, c Challenge) (Solution, error) {
+ sol := make(chan Solution, 1)
+
+ go func() {
+ // A reasonable hardware-based job limiter.
+ // Use 1 worker per thread at most.
+ threads := runtime.NumCPU()
+ for i := 0; i < threads; i++ {
+ go func() {
+ hf := genHashes(ctx, c)
+ // Loop until answer has been found.
+ // Should break when hash worker terminates.
+ for h := range hf {
+ if checkZeros(c.Diff, h.Hash) {
+ h.Salt = c.Salt
+ // Set host url to submit this to.
+ h.host = c.host
+ sol <- h
+ }
+ }
+ }()
+ }
+ }()
+
+ select {
+ case <-ctx.Done():
+ return Solution{}, ctx.Err()
+ case s := <-sol:
+ return s, nil
+ }
+}
diff --git a/cerberus_test.go b/cerberus_test.go
new file mode 100644
index 0000000..92c1de8
--- /dev/null
+++ b/cerberus_test.go
@@ -0,0 +1,100 @@
+package cerberus
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+
+ "golang.org/x/net/proxy"
+)
+
+const _TEST_HOST = "kiwifarms.jp"
+const _TEST_ONION = "kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion"
+
+type errBadZeroCheck struct {
+ Diff uint32
+ Hash []byte
+}
+
+func (e *errBadZeroCheck) Error() string {
+ return fmt.Sprintf("Zero check failed. Diff: %d, Hash: %+v\n", e.Diff, e.Hash)
+}
+
+func solveTest(ctx context.Context, hc http.Client, host string) error {
+ connType := "clearnet"
+ if strings.HasSuffix(host, ".onion") {
+ connType = "tor"
+ }
+
+ log.Printf("Fetching new %s challenge...", connType)
+ c, err := NewChallenge(hc, host)
+ if err != nil {
+ return err
+ }
+ log.Printf("Challenge: %s, Difficulty: %d, Steps: %d\n", c.Salt, c.Diff, c.Steps)
+
+ s, err := Solve(ctx, c)
+ if err != nil {
+ return err
+ }
+ log.Printf("Solution hash: %x, nonce: %d\n", s.Hash, s.Nonce)
+
+ a, err := Submit(hc, s)
+ if err != nil {
+ return err
+ }
+ log.Printf("Response: %s\n\n", a)
+
+ return nil
+}
+
+func newProxyTransport() *http.Transport {
+ pcd := proxy.FromEnvironment().(proxy.ContextDialer)
+
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.DialContext = pcd.DialContext
+
+ return tr
+}
+
+func TestSubmit(t *testing.T) {
+ ctx := t.Context()
+
+ hc := http.Client{}
+
+ err := solveTest(ctx, hc, _TEST_HOST)
+ if err != nil {
+ t.Error(err)
+ }
+
+ hc.Transport = newProxyTransport()
+
+ var dnsErr *net.DNSError
+ err = solveTest(ctx, hc, _TEST_ONION)
+ if err != nil {
+ if errors.As(err, &dnsErr) {
+ log.Println("Unable to resolve .onion domain. Make sure ALL_PROXY is set and tor is running.")
+ log.Println("Skipping...")
+ } else {
+ t.Error(err)
+ }
+ }
+}
+
+func TestCheckZeros(t *testing.T) {
+ d, h := uint32(17), []byte{0, 0, 64, 128, 42}
+ if !checkZeros(d, h) {
+ t.Error(errBadZeroCheck{d, h})
+ }
+
+ // This should fail (i.e. be false).
+ d, h = uint32(3), []byte{33, 130, 222, 88}
+ if checkZeros(d, h) {
+ t.Error(errBadZeroCheck{d, h})
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3fe0925
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module github.com/y-a-t-s/cerberus
+
+go 1.25.5
+
+require (
+ github.com/minio/sha256-simd v1.0.1
+ golang.org/x/net v0.49.0
+)
+
+require (
+ github.com/klauspost/cpuid/v2 v2.2.3 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..705bb85
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,9 @@
+github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
+github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
diff --git a/http.go b/http.go
new file mode 100644
index 0000000..b779bbc
--- /dev/null
+++ b/http.go
@@ -0,0 +1,129 @@
+package cerberus
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+)
+
+var (
+ ErrNoRedirect = errors.New("No redirect to challenge page.")
+ ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.")
+)
+
+func NewChallenge(hc http.Client, host string) (Challenge, error) {
+ u, err := parseHost(host)
+ if err != nil {
+ return Challenge{}, err
+ }
+
+ // Update host url in case we get redirected across domains.
+ hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ rh := req.URL.Host
+ if rh != u.Host && strings.HasPrefix(rh, "kiwifarms") {
+ u.Host = rh
+ }
+
+ return nil
+ }
+
+ resp, err := hc.Get(u.String())
+ if err != nil {
+ return Challenge{}, err
+ }
+ defer resp.Body.Close()
+
+ // Check for 203 status
+ if resp.StatusCode != 203 {
+ return Challenge{}, ErrNoRedirect
+ }
+
+ // Kept separate from the return because of the defer.
+ c, err := parseTags(resp.Body)
+ if err != nil {
+ return Challenge{}, err
+ }
+ c.host = u
+
+ return c, nil
+}
+
+func Submit(hc http.Client, s Solution) (string, error) {
+ resp, err := postSolution(hc, s)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ return resp.Header.Get("Set-Cookie"), nil
+}
+
+func postSolution(hc http.Client, s Solution) (*http.Response, error) {
+ // Ensure the POST url parses properly before passing the string.
+ u, err := url.Parse(fmt.Sprintf("%s://%s/.ttrs/challenge", s.host.Scheme, s.host.Hostname()))
+ if err != nil {
+ return nil, err
+ }
+
+ return hc.PostForm(u.String(), url.Values{
+ "salt": []string{s.Salt},
+ "redirect": []string{s.Redirect},
+ "nonce": []string{fmt.Sprint(s.Nonce)},
+ })
+}
+
+func parseHost(addr string) (*url.URL, error) {
+ // Guess https as protocol if one wasn't provided and hope it parses.
+ if !strings.Contains(addr, "://") {
+ addr = "https://" + addr
+ }
+
+ u, err := url.Parse(addr)
+ if err != nil {
+ return nil, err
+ }
+
+ return u, nil
+}
+
+func parseTags(r io.Reader) (Challenge, error) {
+ c := Challenge{}
+
+ z := html.NewTokenizer(r)
+ for i := z.Next(); i != html.ErrorToken; i = z.Next() {
+ tk := z.Token()
+ if tk.DataAtom == atom.Html {
+ for _, a := range tk.Attr {
+ switch a.Key {
+ case "data-ttrs-challenge":
+ c.Salt = a.Val
+ case "data-ttrs-difficulty":
+ diff, err := strconv.Atoi(a.Val)
+ if err != nil {
+ return c, ErrParseFailed
+ }
+ c.Diff = uint32(diff)
+ case "data-ttrs-steps":
+ steps, err := strconv.Atoi(a.Val)
+ if err != nil {
+ return c, ErrParseFailed
+ }
+ c.Steps = uint32(steps)
+ }
+ }
+ }
+ }
+
+ if c.Salt == "" {
+ return c, ErrParseFailed
+ }
+
+ return c, nil
+}